From f851709eea0eaa8ef2287f58cb525bb646697bc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 09:10:52 +0000 Subject: [PATCH] fix(runtime,plugin-dev): gate every dispatcher domain on handlerReady, and give each dev stub its own honesty class (#4058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4000 made `/analytics` execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability") and left the rest of the class open, because adopting it per-domain needed a decision the issue could not make yet: every plugin-dev stub carried the single `_dev: true` marker, which normalizes to `status: 'stub', handlerReady: false`. One label for "invents answers" AND for "really does the work, in memory" — so no gate could tell the two apart without either 404ing working in-memory implementations or keeping fabricated data on the wire. So the classification comes first, and the gate follows it. Classification — each dev stub declares its own class via the standard `__serviceInfo` marker: - `degraded` (really does the work, reduced capability; `handlerReady` defaults true): `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `realtime`, `workflow`, `metadata`. Their domains keep serving them — which is why `/storage` and `/i18n` do not regress. - `stub` (fabricates): `data`, `auth`, `security.*`, and the shapeless placeholder used for a slot with no factory. - Retired outright, joining `analytics`: `automation`, `notification`, `ai`. Each one's headline method reported success for work that never happened — `execute()` → `{ success: true }` with no flow run, `send()` → a messageId for a message nobody receives, `chat()` → a placeholder answer. An empty slot is one less layer of indirection than a slot that must be ignored, and it is what production already has without the plugin. `_dev: true` stays as the plugin-dev provenance tag (AppPlugin's i18n diagnostic reads it); `__serviceInfo` decides the class. Gate — one predicate, `isServiceServeable` (new `service-serveable.ts`, which absorbed `isAnalyticsServiceServeable`), read by 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. A slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's own 404 or 501 — so what is advertised and what is served cannot disagree. `handlerReady`, not `status`, remains the test. `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` gets better for the stub case: an occupied-but-unserveable slot used to fall through to the `!routes` 503 and lose the `GET /ai/agents` empty list the console polls for. Both are restored. Deliberately NOT gated: `data` / `auth` / `security.*`. Those stubs back the dev stack's own core loop, so gating their domains would 404 the dev stack itself — a separate decision. They are honestly labelled either way. `search` / `realtime` are unaffected: neither has a dispatcher domain. Verified end to end against a real plugin-dev slate: the four retired slots report `unavailable` with no route and 404, `/ai/agents` answers `{ agents: [] }`, and the `degraded` slots keep their routes and serve. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B --- .../dispatcher-handler-ready-class-wide.md | 16 ++ content/docs/kernel/services-checklist.mdx | 33 +++ docs/adr/0076-objectql-core-tiering.md | 4 +- packages/metadata-protocol/src/protocol.ts | 33 ++- .../objectql/src/protocol-discovery.test.ts | 56 +++++ packages/plugins/plugin-dev/README.md | 13 +- .../plugins/plugin-dev/src/dev-plugin.test.ts | 110 +++++++--- packages/plugins/plugin-dev/src/dev-plugin.ts | 141 ++++++++----- packages/runtime/src/dispatcher-plugin.ts | 4 +- packages/runtime/src/domains/ai.ts | 9 +- packages/runtime/src/domains/analytics.ts | 30 +-- packages/runtime/src/domains/automation.ts | 9 +- packages/runtime/src/domains/i18n.ts | 10 +- packages/runtime/src/domains/notifications.ts | 10 +- packages/runtime/src/domains/storage.ts | 9 +- packages/runtime/src/http-dispatcher.test.ts | 191 ++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 67 +++--- packages/runtime/src/service-serveable.ts | 42 ++++ 18 files changed, 645 insertions(+), 142 deletions(-) create mode 100644 .changeset/dispatcher-handler-ready-class-wide.md create mode 100644 packages/runtime/src/service-serveable.ts diff --git a/.changeset/dispatcher-handler-ready-class-wide.md b/.changeset/dispatcher-handler-ready-class-wide.md new file mode 100644 index 0000000000..272b63daa8 --- /dev/null +++ b/.changeset/dispatcher-handler-ready-class-wide.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-dev': patch +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': patch +--- + +Gate every dispatcher service domain on `handlerReady`, not on slot occupancy, and give each plugin-dev stub its own honesty class (#4058). + +#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability") but left the other domains gating on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was still called like a real implementation. The blocker to generalizing it was that every plugin-dev stub carried the single `_dev: true` marker, which normalizes to `status: 'stub'` — one label for "invents answers" and for "really does the work, in memory", so no gate could tell the two apart. + +- **Each dev stub declares its own class** via the standard `__serviceInfo` marker: `degraded` for the fakes that really do the work (`cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `realtime`, `workflow`, `metadata`) and `stub` for the ones that fabricate (`data`, `auth`, `security.*`, and the shapeless placeholder used for a slot with no factory). `_dev: true` stays as the plugin-dev provenance tag; `__serviceInfo` decides the class. +- **The dispatcher-owned domains** (`/analytics`, `/automation`, `/notifications`, `/ai`, `/storage`, `/i18n`), the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement all read one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly like an empty slot — the domain's existing 404 or 501 — so what is advertised and what is served cannot disagree. A `degraded` implementation defaults to `handlerReady: true` and keeps serving, which is why `/storage` and `/i18n` 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 "routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for. Both are restored. + +FROM → TO for dev setups that relied on a retired stub. `plugin-dev` no longer registers `automation`, `notification` or `ai` (joining `analytics` from #4000) — each one's headline method reported success for work that never happened (`execute()` → `{ success: true }` with no flow run, `send()` → a messageId for a message nobody receives, `chat()` → a placeholder answer). Install the real service instead: `@objectstack/service-automation`, `@objectstack/service-messaging`, or an AI service. Nothing changes for `os serve` / `os dev`, where `messaging` and `analytics` are already in `ALWAYS_ON_CAPABILITIES`. Deliberately unchanged: the `data` / `auth` / `security.*` dev stubs keep their slots (the dev stack's core loop resolves them) — they are now honestly labelled `stub`, but their domains are not gated. diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index 0f9f3a36d9..95b334da5b 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -135,6 +135,31 @@ 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. The two values mean different things, and 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 @@ -193,6 +218,14 @@ 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): `plugin-dev`'s `automation`, +`notification` and `ai` dev stubs are retired for the same reason — each +reported success for work that never happened — and every dispatcher-owned +domain now gates on `handlerReady`. The dev stubs that really do the work in +memory (`file-storage`, `i18n`, `cache`, `queue`, `job`, `search`, `realtime`, +`workflow`, `metadata`) declare `degraded` and keep serving. See +[`stub` vs `degraded`](#stub-vs-degraded--and-what-the-dispatcher-does-with-each). ### Features (via `@objectstack/service-analytics`) diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index c8228a5ca1..ab12fb324d 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -132,7 +132,7 @@ 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: `automation` / `notification` / `ai` joined `analytics` in retirement — same defect, same fix — and the survivors stopped sharing one marker. Each dev stub now declares its own honesty class via `__serviceInfo`: `degraded` when it really does the work in memory, `stub` when it fabricates. `_dev: true` remains as the plugin-dev provenance tag only; it no longer decides the class, and the "nothing respects it" of the original finding is now "everything respects the declaration it normalizes to".)* - `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available). - `http-dispatcher.ts` `svcAvailable` — the hardcode above. @@ -141,6 +141,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 — the per-domain call, made. 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. What made it safe to generalize was **splitting the honesty class first**: the blocker recorded in #4000 was that one `_dev: true` marker labelled "invents answers" and "really works, in memory" identically, so no gate could tell them apart. plugin-dev now declares `__serviceInfo` per stub — `degraded` for the fakes that really do the work (`cache`/`queue`/`job`/`file-storage`/`search`/`i18n`/`realtime`/`workflow`/`metadata`: `handlerReady` defaults `true`, so their domains keep serving them, which is why `/storage` and `/i18n` did not regress) and `stub` for the ones that fabricate. The four whose headline method reported success for work that never happened (`analytics`, `automation.execute`, `notification.send`, `ai.chat`) are not labelled but **retired** — an empty slot is one less layer of indirection than a slot that must be ignored, and it is what production already has. Deliberately NOT gated: `data` / `auth` / `security.*`, whose stubs back the dev stack's core loop — they are honestly labelled `stub` (so discovery reports them as such) but gating their domains would 404 the dev stack itself, which is a separate decision. `search` / `realtime` are unaffected either way: neither has a dispatcher domain.)* + 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.)* diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6e7cdb7c50..02702eb2c7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -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)) { diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 8601aead43..59fcc56a75 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -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(); + 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(); + 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(); + 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(); mockServices.set('file-storage', {}); diff --git a/packages/plugins/plugin-dev/README.md b/packages/plugins/plugin-dev/README.md index 6820322510..c4c7c2f433 100644 --- a/packages/plugins/plugin-dev/README.md +++ b/packages/plugins/plugin-dev/README.md @@ -77,11 +77,18 @@ plugins: [ ### Dev stubs (in-memory / no-op) -Any core kernel service not provided by a real plugin is automatically registered as a dev stub. This ensures the **full kernel service map** is populated and features like UI permissions, automation, etc. don't crash: +Most core kernel services not provided by a real plugin are automatically registered as a dev stub, so the **kernel service map** is populated and callers get correct return types instead of `undefined`. -`cache`, `queue`, `job`, `file-storage`, `search`, `automation`, `graphql`, `analytics`, `realtime`, `notification`, `ai`, `i18n`, `ui`, `workflow`, `security.permissions`, `security.rls`, `security.fieldMasker` +Each stub declares what kind of fake it is (`__serviceInfo`, ADR-0076 D12), because consumers gate on the difference: -All services are **optional** — if a peer package isn't installed, it is silently skipped and a stub takes its place. +| Class | Slots | Meaning | +|:---|:---|:---| +| `degraded` | `cache`, `queue`, `job`, `file-storage`, `search`, `realtime`, `i18n`, `workflow`, `metadata` | Really does the work, in memory only — nothing persists, nothing crosses a process boundary. Served normally over HTTP. | +| `stub` | `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`, plus `ui` (placeholder) | Fabricates its answers (allow-all permissions, discarded writes). Reported as a stub in discovery; consumers that honour `handlerReady` treat the slot as empty. | + +**Not stubbed at all** — `analytics`, `automation`, `notification`, `ai`. These fakes reported success for work that never happened (a flow "executed" without running, a notification "sent" to nobody, a placeholder AI answer), and the dispatcher served that as a 200. The slots stay **empty**, exactly as in production without the plugin: `/api/v1/{analytics,automation,notifications,ai}/*` answer 404 and discovery reports `unavailable`. Install the real service to use the capability locally — `@objectstack/service-analytics`, `@objectstack/service-automation`, `@objectstack/service-messaging`, or an AI service. (#4000, #4058) + +All services are **optional** — if a peer package isn't installed, it is silently skipped and, for the slots above, a stub takes its place. ## API Endpoints (when all services enabled) diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index 72d9e39c27..74a0094207 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, SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; import { DevPlugin } from './dev-plugin'; // #3060: init()'s graceful-degradation path dynamically imports ~10 real @@ -67,7 +68,7 @@ describe('DevPlugin', () => { await expect(plugin.init(ctx)).resolves.not.toThrow(); }); - it('should register contract-compliant dev stubs for every core service except analytics', async () => { + it('should register contract-compliant dev stubs for every core service except the fabricating ones', async () => { const registeredServices = new Map(); const ctx: any = { logger: { @@ -111,6 +112,9 @@ describe('DevPlugin', () => { (call: any[]) => typeof call[0] === 'string' && call[0].includes('dev stubs registered'), ); expect(stubLog).toBeDefined(); + // Split the announced list instead of substring-matching it below: `ai` is + // two letters and would match inside another service's name. + const announcedStubs = stubLog[0].split(':').pop().split(',').map((s: string) => s.trim()); // ── Verify ICacheService contract ── const cache = registeredServices.get('cache'); @@ -153,39 +157,27 @@ describe('DevPlugin', () => { expect(searchResult.hits).toHaveLength(1); expect(typeof searchResult.totalHits).toBe('number'); - // ── Verify IAutomationService contract ── - const automation = registeredServices.get('automation'); - const execResult = await automation.execute('test_flow'); - expect(execResult.success).toBe(true); - expect(Array.isArray(await automation.listFlows())).toBe(true); - - // ── analytics: deliberately NOT stubbed (#4000) ── + // ── analytics / automation / notification / ai: deliberately NOT stubbed + // (#4000, #4058) ── // #3891/#3989 retired the degraded analytics shim so an empty slot is the // honest signal (route unmounted → 404, discovery `unavailable`). A dev // stub refilled the slot and the dispatcher, gating on presence alone, // served its fabricated rows with a 200 — the retired shape, one layer - // down. The slot stays empty; install @objectstack/service-analytics. - expect(registeredServices.has('analytics')).toBe(false); - expect(stubLog[0]).not.toContain('analytics'); + // down. #4058 found the same shape in the other fakes whose headline method + // reports success for work that never happened (`automation.execute` → + // `{ success: true }` with no flow run, `notification.send` → a messageId + // for a message nobody receives, `ai.chat` → a placeholder answer), and + // retired them too. The slots stay empty; install the real services. + for (const slot of ['analytics', 'automation', 'notification', 'ai']) { + expect(registeredServices.has(slot)).toBe(false); + expect(announcedStubs).not.toContain(slot); + } // ── Verify IRealtimeService contract ── const realtime = registeredServices.get('realtime'); const subId = await realtime.subscribe('ch', () => {}); expect(typeof subId).toBe('string'); - // ── Verify INotificationService contract ── - const notif = registeredServices.get('notification'); - const notifResult = await notif.send({ channel: 'email', to: 'test@dev', body: 'hello' }); - expect(notifResult.success).toBe(true); - expect(typeof notifResult.messageId).toBe('string'); - - // ── Verify IAIService contract ── - const ai = registeredServices.get('ai'); - const chatResult = await ai.chat([{ role: 'user', content: 'hi' }]); - expect(typeof chatResult.content).toBe('string'); - expect(typeof chatResult.model).toBe('string'); - expect(Array.isArray(await ai.listModels())).toBe(true); - // ── Verify II18nService contract ── const i18n = registeredServices.get('i18n'); i18n.loadTranslations('en', { 'hello': 'Hello {{name}}' }); @@ -214,6 +206,76 @@ describe('DevPlugin', () => { // The stubs follow the same contracts as the real implementations. }); + // [#4058] The honesty class of each dev stub, which is the whole point of the + // change: consumers gate on `handlerReady` (ADR-0076 D12 conclusion 3, which + // the dispatcher executes since #4000/#4058), so a fake that really does the + // work in memory must NOT be labelled the same as one that fabricates. Every + // stub used to carry only `_dev: true`, which normalizes to `{ status: + // 'stub', handlerReady: false }` — one label for both kinds, which is exactly + // why the class could not be gated. Each stub now declares its own. + it('declares an honesty class per dev stub — degraded for real in-memory work, stub for fabricated', 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(), + }; + + // `auth` and `security` left ENABLED so their stubs are registered: the + // real plugins are mocked to be missing (top of file), so init degrades to + // the dev stubs — which is the shape whose labelling this test pins. + await new DevPlugin({ + seedAdminUser: false, + services: { + objectql: false, driver: false, setup: false, + server: false, rest: false, dispatcher: false, + }, + }).init(ctx); + + // Really serves, in memory only → `degraded`, `handlerReady: true`, so its + // dispatcher domain (where it has one) keeps serving it. + for (const svc of ['cache', 'queue', 'job', 'file-storage', 'search', 'i18n', 'realtime', 'workflow', 'metadata']) { + const info = readServiceSelfInfo(registeredServices.get(svc)); + expect(info, `${svc} must self-declare`).toBeDefined(); + expect(info!.status, `${svc} really does the work in memory`).toBe('degraded'); + expect(info!.handlerReady, `${svc} genuinely serves`).toBe(true); + expect(info!.message, `${svc} must say what is reduced`).toBeTruthy(); + } + + // Fabricates its answers → `stub`, `handlerReady: false`: consumers treat + // the slot as empty. (`data`/`auth`/`security.*` keep their slots because + // the dev stack's core loop resolves them; they are honestly labelled, and + // their domains are deliberately not gated — see #4058.) + for (const svc of ['data', 'auth', 'security.permissions', 'security.rls', 'security.fieldMasker']) { + const info = readServiceSelfInfo(registeredServices.get(svc)); + expect(info, `${svc} must self-declare`).toBeDefined(); + expect(info!.status, `${svc} fabricates`).toBe('stub'); + expect(info!.handlerReady, `${svc} is not a real handler`).toBe(false); + expect(info!.message, `${svc} must say what is fake`).toBeTruthy(); + } + + // A slot with no factory gets the shapeless fallback — no contract method + // at all, so it is the purest non-handler and declares `stub`. + const ui = readServiceSelfInfo(registeredServices.get('ui')); + expect(ui?.status).toBe('stub'); + expect(ui?.handlerReady).toBe(false); + + // `_dev: true` stays as the provenance tag, but never decides the class: + // `__serviceInfo` wins in readServiceSelfInfo, so a `degraded` stub reports + // `degraded` even though the legacy marker alone would say `stub`. + expect(registeredServices.get('cache')._dev).toBe(true); + expect(registeredServices.get('cache')[SERVICE_SELF_INFO_KEY].status).toBe('degraded'); + }); + 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..8c53678817 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. @@ -32,13 +33,54 @@ const SECURITY_SERVICE_NAMES = [ * Where an interface method is optional (marked with `?`), the stub only * implements the required methods plus any optional ones that have * a trivially useful implementation. + * + * ── Honesty class: every stub declares its own (#4058) ────────────────────── + * + * These fakes are not one kind of thing, and until #4058 they all carried the + * single `_dev: true` marker, which `readServiceSelfInfo` normalizes to + * `{ status: 'stub', handlerReady: false }`. That one-size-fits-all label is + * what made the class impossible to reason about: it says the in-memory file + * store (which really stores and really reads back) is the same kind of fake as + * the AI stub (which invents answers). Consumers must gate on the difference — + * ADR-0076 D12 conclusion 3, executed by the dispatcher since #4000/#4058 — so + * the difference has to be *declared*, per stub, in the standard place: + * + * - {@link devDegraded} — functional in-memory implementation, reduced + * capability (no persistence, no cross-process fan-out). `handlerReady: true`: + * an HTTP domain backed by one of these serves real work and keeps serving. + * - {@link devStub} — placeholder that fabricates a result. `handlerReady: + * false`: consumers treat the slot as EMPTY. Fakes that would answer an HTTP + * request with invented data (`analytics`, `automation`, `notification`, `ai`) + * are not marked at all — they are not registered (see + * {@link NO_DEV_STUB_SERVICES}), because an empty slot is one less layer of + * indirection than a slot that is occupied but must be ignored. + * + * `_dev: true` stays on every stub as the plugin-dev *provenance* tag (log + * diagnostics such as AppPlugin's i18n fallback notice read it). It is only the + * fallback interpretation for a service that declares nothing — `__serviceInfo` + * always wins in `readServiceSelfInfo`. */ +/** `__serviceInfo` for a fake that really does the work, in memory only. */ +function devDegraded(message: string) { + return { + [SERVICE_SELF_INFO_KEY]: { status: 'degraded' as const, message }, + }; +} + +/** `__serviceInfo` for a fake that fabricates its answers — treat as an empty slot. */ +function devStub(message: string) { + return { + [SERVICE_SELF_INFO_KEY]: { status: 'stub' as const, message }, + }; +} + /** IStorageService — in-memory file storage stub */ function createStorageStub() { const files = new Map(); return { _dev: true, _serviceName: 'file-storage', + ...devDegraded('In-memory dev file store (plugin-dev) — uploads are real but process-local and lost on restart; install @objectstack/service-storage for persistence'), async upload(key: string, data: any, options?: any): Promise { files.set(key, { data: Buffer.from(data), meta: { contentType: options?.contentType, metadata: options?.metadata } }); }, @@ -61,6 +103,7 @@ function createSearchStub() { const indexes = new Map>>(); return { _dev: true, _serviceName: 'search', + ...devDegraded('In-memory dev search index (plugin-dev) — real substring matching over documents indexed in this process, no ranking or analyzers'), async index(object: string, id: string, document: Record): Promise { if (!indexes.has(object)) indexes.set(object, new Map()); indexes.get(object)!.set(id, document); @@ -84,25 +127,13 @@ function createSearchStub() { }; } -/** IAutomationService — no-op flow execution stub */ -function createAutomationStub() { - const flows = new Map(); - return { - _dev: true, _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); }, - unregisterFlow(name: string) { flows.delete(name); }, - }; -} - - /** IRealtimeService — in-memory pub/sub stub */ function createRealtimeStub() { const subs = new Map(); let subId = 0; return { _dev: true, _serviceName: 'realtime', + ...devDegraded('In-process dev pub/sub (plugin-dev) — events really reach subscribers in this process, but channel filtering and cross-process fan-out are not implemented'), 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; @@ -111,34 +142,13 @@ function createRealtimeStub() { }; } -/** INotificationService — in-memory log stub */ -function createNotificationStub() { - const sent: any[] = []; - return { - _dev: true, _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; }, - }; -} - -/** IAIService — dev stub returning placeholder responses */ -function createAIStub() { - return { - _dev: true, _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]]; }, - async listModels() { return ['dev-stub']; }, - }; -} - /** II18nService — delegates to createMemoryI18n from core with locale fallback */ function createI18nStub() { const base = createMemoryI18n(); return { ...base, _dev: true, _serviceName: 'i18n', + ...devDegraded('In-memory dev translations (plugin-dev) — bundles loaded in this process are really served; install @objectstack/service-i18n for DB-backed translations'), }; } @@ -148,6 +158,7 @@ function createWorkflowStub() { const key = (obj: string, id: string) => `${obj}:${id}`; return { _dev: true, _serviceName: 'workflow', + ...devDegraded('In-memory dev workflow state (plugin-dev) — transitions are really recorded and read back, but no state machine is enforced and no history is kept'), async transition(t: any) { states.set(key(t.object, t.recordId), t.targetState); return { success: true, currentState: t.targetState }; @@ -164,6 +175,7 @@ function createMetadataStub() { const store = new Map>(); // type → (name → def) return { _dev: true, _serviceName: 'metadata', + ...devDegraded('In-memory dev metadata registry (plugin-dev) — registrations are really stored and queried, but nothing persists to sys_metadata'), register(type: string, nameOrDef: string | Record, data?: unknown) { if (!store.has(type)) store.set(type, new Map()); if (typeof nameOrDef === 'object' && nameOrDef !== null) { @@ -200,6 +212,7 @@ function createMetadataStub() { function createAuthStub() { return { _dev: true, _serviceName: 'auth', + ...devStub('Dev auth stub (plugin-dev) — every request is accepted as a fabricated platform admin; install @objectstack/plugin-auth for real authentication'), 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() {}, @@ -211,6 +224,7 @@ function createAuthStub() { function createDataStub() { return { _dev: true, _serviceName: 'data', + ...devStub('Dev data stub (plugin-dev) — reads answer empty and writes are discarded while reporting success; register a driver + ObjectQL for a real engine'), async find() { return []; }, async findOne() { return undefined; }, async insert(_obj: string, params: any) { return { id: `dev-${Date.now()}`, ...params?.data }; }, @@ -225,6 +239,7 @@ function createDataStub() { function createSecurityPermissionsStub() { return { _dev: true, _serviceName: 'security.permissions', + ...devStub('Dev permission stub (plugin-dev) — every object permission check answers "allowed"; install SecurityPlugin for real RBAC'), resolvePermissionSets() { return []; }, checkObjectPermission() { return true; }, getFieldPermissions() { return {}; }, @@ -233,6 +248,7 @@ function createSecurityPermissionsStub() { function createSecurityRLSStub() { return { _dev: true, _serviceName: 'security.rls', + ...devStub('Dev RLS stub (plugin-dev) — compiles no row filter, so no row-level policy is applied; install SecurityPlugin for real RLS'), compileFilter() { return null; }, getApplicablePolicies() { return []; }, }; @@ -240,6 +256,7 @@ function createSecurityRLSStub() { function createSecurityFieldMaskerStub() { return { _dev: true, _serviceName: 'security.fieldMasker', + ...devStub('Dev field-masker stub (plugin-dev) — returns rows unmasked; install SecurityPlugin for real field-level masking'), maskResults(results: any) { return results; }, }; } @@ -250,15 +267,12 @@ 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(), _dev: true, ...devDegraded('In-memory dev cache (plugin-dev) — really caches, process-local and lost on restart') }), + 'queue': () => ({ ...createMemoryQueue(), _dev: true, ...devDegraded('In-memory dev queue (plugin-dev) — really enqueues and delivers in-process, with no durability') }), + 'job': () => ({ ...createMemoryJob(), _dev: true, ...devDegraded('In-memory dev job scheduler (plugin-dev) — really schedules in-process, with no durability across restarts') }), 'file-storage': createStorageStub, 'search': createSearchStub, - 'automation': createAutomationStub, 'realtime': createRealtimeStub, - 'notification': createNotificationStub, - 'ai': createAIStub, 'i18n': createI18nStub, 'workflow': createWorkflowStub, 'metadata': createMetadataStub, @@ -275,6 +289,14 @@ const DEV_STUB_FACTORIES: Record Record> = { * shapeless `{ _dev: true }` fallback the registration loop uses for slots * without a factory. * + * The membership rule is the honesty class above: a fake that would answer a + * caller with **fabricated data** does not belong in a canonical slot at all. + * An empty slot is one less layer of indirection than a slot that is occupied + * but must be ignored, and it is the state production already has when the real + * plugin isn't installed — so dev and production agree on what "this capability + * exists" means. Fakes that really do the work (in memory) are NOT here: they + * declare `degraded` and stay registered. + * * `analytics` (#4000): #3891/#3989 retired the degraded analytics shim, making * an unoccupied slot the honest signal — `/analytics/*` is not mounted, the * request 404s, and discovery reports `unavailable`. Registering a dev stub @@ -285,8 +307,27 @@ const DEV_STUB_FACTORIES: Record Record> = { * but "the capability is present" must mean the same thing in dev as in * production. To use analytics locally, install the real engine: * `@objectstack/service-analytics` runs an InMemory strategy. + * + * The next three are the rest of that class (#4058), retired for the same + * reason — each one's headline method reported success for work that never + * happened: + * + * - `automation`: `execute()` returned `{ success: true, durationMs: 0 }` + * without running a flow, and the `/automation` domain served that as a 200 — + * the same "200 + fabricated data" shape #3891 retired. Install + * `@objectstack/service-automation` to run flows locally. + * - `notification`: `send()` pushed the message into an array and reported + * `{ success: true, messageId }`, so a caller read "notification sent" for + * one that was never delivered. Install `@objectstack/service-messaging` + * (ADR-0030's single ingress, and part of `os serve`'s always-on slate). + * - `ai`: answered every prompt with a placeholder string. ADR-0076 D12 names + * this one as having already misled an agent. Install a real AI service — + * and note that with the slot empty the console's `GET /ai/agents` poll gets + * its honest empty list back, instead of the 503 the occupied slot produced. */ -const NO_DEV_STUB_SERVICES = new Set(['analytics']); +const NO_DEV_STUB_SERVICES = new Set([ + 'analytics', 'automation', 'notification', 'ai', +]); /** * Dev Plugin Options @@ -664,7 +705,17 @@ export class DevPlugin implements Plugin { // dev stub (implementing the interface from packages/spec/src/contracts/) // 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). + // Exception: NO_DEV_STUB_SERVICES slots stay empty on purpose (#4000/#4058). + // + // A slot with no factory gets the shapeless fallback, which implements no + // contract method at all — the purest non-handler there is, so it declares + // `stub` (#4058) and every consumer that honours ADR-0076 D12 treats it as + // the empty slot it effectively is. + const shapelessStub = (svc: string) => ({ + _dev: true, + _serviceName: svc, + ...devStub(`Placeholder dev slot (plugin-dev) — no ${svc} implementation is registered`), + }); const stubNames: string[] = []; @@ -676,7 +727,7 @@ export class DevPlugin implements Plugin { // 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, factory ? factory() : shapelessStub(svc)); stubNames.push(svc); } } @@ -688,7 +739,7 @@ export class DevPlugin implements Plugin { ctx.getService(svc); } catch { const factory = DEV_STUB_FACTORIES[svc]; - ctx.registerService(svc, factory ? factory() : { _dev: true, _serviceName: svc }); + ctx.registerService(svc, factory ? factory() : shapelessStub(svc)); stubNames.push(svc); } } diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index e10d44e544..b1b0ce64db 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -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 { @@ -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; } diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index 674061b5fa..e6989b0cb0 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -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'; @@ -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 — diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 90366392f7..929077c35b 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -8,11 +8,13 @@ * ExecutionContext and the contract `where` filter). Route registration stays * dispatcher-owned so the URL contract is stable regardless of what occupies * the slot; an empty slot answers the `handled: false` 404 below — as does a - * slot occupied by a self-declared stub (#4000, see {@link isAnalyticsServiceServeable}). + * slot occupied by a self-declared stub (#4000, see {@link isServiceServeable}, + * the shared predicate every service domain reads since #4058). */ import { CoreServiceName } from '@objectstack/spec/system'; -import { AnalyticsQueryRequestSchema, readServiceSelfInfo } from '@objectstack/spec/api'; +import { AnalyticsQueryRequestSchema } from '@objectstack/spec/api'; +import { isServiceServeable } from '../service-serveable.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -71,28 +73,6 @@ function assertAnalyticsQueryBody(body: unknown): void { } } -/** - * [#4000] Is the thing occupying the `analytics` slot something we may call - * like a real engine? - * - * ADR-0076 D12 gave services a way to self-declare that they are a stub or a - * degraded fallback, and its third conclusion binds *consumers*: only - * `handlerReady: true` counts as a real capability. Discovery honoured that - * from day one; the dispatcher — a consumer too — gated on mere presence, so - * anything registered in the slot got called and its fabricated rows went back - * as a 200. That is precisely the shape #3891 retired one layer up, and - * plugin-dev's analytics stub kept it alive in dev mode (retired with this - * change; this predicate is what stops the next one). - * - * `handlerReady` (not `status`) is the right test: a `degraded` implementation - * that genuinely serves requests defaults to `true` and keeps serving — only a - * self-confessed non-handler is treated as an empty slot. - */ -export function isAnalyticsServiceServeable(svc: unknown): boolean { - if (!svc) return false; - return readServiceSelfInfo(svc)?.handlerReady !== false; -} - export function createAnalyticsDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/analytics', @@ -113,7 +93,7 @@ export async function handleAnalyticsRequest( const analyticsService = await deps.getService(CoreServiceName.enum.analytics); // Empty slot — or a slot filled by a self-declared stub (#4000), which is // the same amount of analytics capability. 404 handled by caller. - if (!isAnalyticsServiceServeable(analyticsService)) return { handled: false }; + if (!isServiceServeable(analyticsService)) return { handled: false }; const m = method.toUpperCase(); const subPath = path.replace(/^\/+/, ''); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 1527754f6a..2091900dff 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -10,6 +10,7 @@ */ import { CoreServiceName } from '@objectstack/spec/system'; +import { isServiceServeable } from '../service-serveable.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -42,7 +43,13 @@ export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute { */ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise { const automationService = await deps.getService(CoreServiceName.enum.automation); - if (!automationService) return { handled: false }; + // [#4058] Empty slot — or a slot filled by a self-declared non-handler + // (`handlerReady: false`, ADR-0076 D12), which is the same amount of + // automation capability. This domain is the sharpest case for the rule: a + // stub whose `execute` returns `{ success: true }` without running anything + // answered 200, so a caller (or an agent) read "flow executed" off a flow + // that never ran. 404 handled by caller. + if (!isServiceServeable(automationService)) return { handled: false }; const m = method.toUpperCase(); const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index 2a0b1e1117..c25e714105 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -18,6 +18,7 @@ import { resolveLocale } from '@objectstack/core'; import { CoreServiceName, resolveObjectFieldLabels, toLocaleDescriptors } from '@objectstack/spec/system'; +import { isServiceServeable } from '../service-serveable.js'; import type { TranslationData } from '@objectstack/spec/system'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -39,7 +40,14 @@ export async function handleI18nRequest( _context: HttpProtocolContext, ): Promise { const i18nService = await deps.getService(CoreServiceName.enum.i18n); - if (!i18nService) return { handled: true, response: deps.error('i18n service not available', 501) }; + // [#4058] An empty slot and a slot filled by a self-declared non-handler + // (`handlerReady: false`, ADR-0076 D12) are the same amount of i18n. Neither + // in-memory provider of this slot is affected: plugin-dev's stub really + // translates and now declares `degraded`, and the AppPlugin fallback + // (`createMemoryI18n`) declares nothing at all, which `readServiceSelfInfo` + // reads as "claims to be real" ⇒ served. This gate is for an occupant that + // would answer with invented strings. + if (!isServiceServeable(i18nService)) return { handled: true, response: deps.error('i18n service not available', 501) }; const m = method.toUpperCase(); const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); diff --git a/packages/runtime/src/domains/notifications.ts b/packages/runtime/src/domains/notifications.ts index 90a36c45ca..ac964b7c17 100644 --- a/packages/runtime/src/domains/notifications.ts +++ b/packages/runtime/src/domains/notifications.ts @@ -20,6 +20,7 @@ */ import { CoreServiceName } from '@objectstack/spec/system'; +import { isServiceServeable } from '../service-serveable.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -41,7 +42,14 @@ export async function handleNotificationRequest( context: HttpProtocolContext, ): Promise { const service = await deps.resolveService(CoreServiceName.enum.notification, context.environmentId) as any; - if (!service || typeof service.listInbox !== 'function') return { handled: false }; + // [#4058] Three ways to have no inbox capability, one answer. The + // `listInbox` duck-type was already here and, by accident, kept every + // fabricating stub off this surface (plugin-dev's notification stub + // implements `send`/`sendBatch` only). `isServiceServeable` makes that + // explicit rather than incidental: a self-declared non-handler + // (`handlerReady: false`, ADR-0076 D12) is an empty slot even if it grows a + // `listInbox` later. A `degraded` inbox that really serves keeps serving. + if (!isServiceServeable(service) || typeof service.listInbox !== 'function') return { handled: false }; const userId: string | undefined = context.executionContext?.userId; if (!userId) { diff --git a/packages/runtime/src/domains/storage.ts b/packages/runtime/src/domains/storage.ts index 9a8c9c2207..24e85f36e0 100644 --- a/packages/runtime/src/domains/storage.ts +++ b/packages/runtime/src/domains/storage.ts @@ -12,6 +12,7 @@ */ import { CoreServiceName } from '@objectstack/spec/system'; +import { isServiceServeable } from '../service-serveable.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -36,7 +37,13 @@ export async function handleStorageRequest( // already ends at the services map (and when `services` is a Map, the // legacy index access returned undefined anyway), so it is dropped here. const storageService = await deps.getService(CoreServiceName.enum['file-storage']); - if (!storageService) { + // [#4058] Same 501 for an empty slot and for a slot filled by a + // self-declared non-handler (`handlerReady: false`, ADR-0076 D12) — "not + // configured" is the honest answer to both. NOT aimed at plugin-dev's + // in-memory storage stub: that one really stores and really reads back, so + // it declares `degraded` (`handlerReady: true`) and still serves here. The + // gate exists for the occupant that only *claims* to store. + if (!isServiceServeable(storageService)) { return { handled: true, response: deps.error('File storage not configured', 501) }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 196062859d..75bb234dff 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2389,6 +2389,197 @@ describe('HttpDispatcher', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // [#4058] handlerReady across the REST of the service domains + // + // #4000 executed ADR-0076 D12 conclusion 3 for `analytics` alone; the other + // domains kept gating on "is the slot occupied", so a self-declared stub in + // any of them was still called like a real implementation. These pin the + // class-wide rule and, just as importantly, its limit: `degraded` — an + // implementation that really serves with reduced capability — is NOT + // affected. A single predicate decides both (`service-serveable.ts`), so the + // route/feature advertisement and the handler cannot disagree. + // ═══════════════════════════════════════════════════════════════ + + describe('handlerReady gating across service domains (D12 / #4058)', () => { + /** A fake that fabricates: every method answers, none does the work. */ + const stubbed = (methods: Record) => ({ + __serviceInfo: { status: 'stub', message: 'dev fake' }, + ...methods, + }); + /** A fake that really does the work in memory — `handlerReady` defaults true. */ + const degraded = (methods: Record) => ({ + __serviceInfo: { status: 'degraded', message: 'in-memory' }, + ...methods, + }); + + const serveOnly = (name: string, svc: unknown) => { + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === name ? svc : null)); + (kernel as any).services = new Map([[name, svc]]); + }; + + // The sharpest case in the class: `execute` reported `{ success: true }` + // for a flow that never ran, and the domain served it as a 200 — a + // caller (or an agent) read "flow executed" off nothing happening. + it('/automation — a stub slot is an empty slot, and is never called', async () => { + const stub = stubbed({ + execute: vi.fn().mockResolvedValue({ success: true, output: undefined, durationMs: 0 }), + trigger: vi.fn().mockResolvedValue({ success: true }), + listFlows: vi.fn().mockResolvedValue([]), + registerFlow: vi.fn(), + }); + serveOnly('automation', stub); + + for (const [path, method] of [['', 'GET'], ['', 'POST'], ['trigger/x', 'POST'], ['x/trigger', 'POST']] as const) { + const result = await dispatcher.handleAutomation(path, method, { name: 'x' }, { request: {} }); + expect(result.handled, `${method} /automation/${path}`).toBe(false); + } + expect(stub.execute).not.toHaveBeenCalled(); + expect(stub.trigger).not.toHaveBeenCalled(); + expect(stub.listFlows).not.toHaveBeenCalled(); + expect(stub.registerFlow).not.toHaveBeenCalled(); + }); + + it('/automation — a degraded engine keeps serving', async () => { + const svc = degraded({ listFlows: vi.fn().mockResolvedValue(['flow_a']) }); + serveOnly('automation', svc); + + const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.body?.data?.flows).toEqual(['flow_a']); + }); + + // Storage answers 501 for an empty slot rather than `handled: false`, so + // "same answer as an empty slot" means the same 501 here. + it('/storage — a stub slot answers the not-configured 501 without being called', async () => { + const stub = stubbed({ upload: vi.fn(), download: vi.fn() }); + serveOnly('file-storage', stub); + + const result = await dispatcher.handleStorage('upload', 'POST', { name: 'f.txt' }, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + expect(stub.upload).not.toHaveBeenCalled(); + }); + + // The half that protects plugin-dev's in-memory file store: it really + // stores and really reads back, so it declares `degraded` and serves. + it('/storage — a degraded in-memory store keeps serving', async () => { + const svc = degraded({ upload: vi.fn().mockResolvedValue({ key: 'f.txt' }) }); + serveOnly('file-storage', svc); + + const result = await dispatcher.handleStorage('upload', 'POST', { name: 'f.txt' }, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(svc.upload).toHaveBeenCalled(); + }); + + it('/i18n — a stub slot answers the not-available 501; a degraded provider serves', async () => { + const stub = stubbed({ getLocales: vi.fn().mockReturnValue(['xx']) }); + serveOnly('i18n', stub); + const stubResult = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + expect(stubResult.response?.status).toBe(501); + expect(stub.getLocales).not.toHaveBeenCalled(); + + const svc = degraded({ + getLocales: vi.fn().mockReturnValue(['en']), + getDefaultLocale: vi.fn().mockReturnValue('en'), + }); + serveOnly('i18n', svc); + const okResult = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + expect(okResult.response?.status).toBe(200); + expect(svc.getLocales).toHaveBeenCalled(); + }); + + // The inbox surface was already protected by accident — the `listInbox` + // duck-type kept `send`-only fakes out. Now it is protected on purpose, + // so a stub that grows a `listInbox` stays out too. + it('/notifications — a stub slot is an empty slot even when it implements listInbox', async () => { + const stub = stubbed({ + listInbox: vi.fn().mockResolvedValue({ messages: [] }), + send: vi.fn().mockResolvedValue({ success: true, messageId: 'x' }), + }); + serveOnly('notification', stub); + + const result = await dispatcher.handleNotification('', 'GET', undefined, {}, { + request: {}, executionContext: { userId: 'usr_1' }, + } as any); + expect(result.handled).toBe(false); + expect(stub.listInbox).not.toHaveBeenCalled(); + }); + + // Being truthy, a stub used to fall through to the `!routes` 503 — which + // reads as a fault AND loses the empty-list courtesy the console's + // per-navigation `GET /ai/agents` poll depends on. Treating it as an + // empty slot restores both. + it('/ai — a stub slot 404s per route and keeps the /ai/agents empty list', async () => { + const stub = stubbed({ chat: vi.fn(), listModels: vi.fn() }); + serveOnly('ai', stub); + + const chat = await dispatcher.handleAI('/ai/chat', 'POST', { messages: [] }, {}, { request: {} }); + expect(chat.handled).toBe(true); + expect(chat.response?.status).toBe(404); + expect(stub.chat).not.toHaveBeenCalled(); + + const agents = await dispatcher.handleAI('/ai/agents', 'GET', undefined, {}, { request: {} }); + expect(agents.handled).toBe(true); + expect(agents.response?.status).toBe(200); + expect(agents.response?.body).toEqual({ agents: [] }); + }); + + // Discovery must say exactly what the domains do — one predicate feeds + // both. `services.*` deliberately stays presence-gated: a registered + // stub self-reporting `status: 'stub'` says strictly more than + // `unavailable` / "install a plugin" would. + it('stops advertising routes/features for stub slots, while still reporting them as stubs', async () => { + const stubs: Record = { + 'file-storage': stubbed({ upload: vi.fn() }), + automation: stubbed({ execute: vi.fn() }), + notification: stubbed({ send: vi.fn() }), + ai: stubbed({ chat: vi.fn() }), + i18n: stubbed({ getLocales: vi.fn().mockReturnValue([]) }), + }; + (kernel as any).getService = vi.fn().mockImplementation((n: string) => stubs[n] ?? null); + (kernel as any).services = new Map(Object.entries(stubs)); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + for (const key of ['storage', 'automation', 'notifications', 'ai', 'i18n'] as const) { + expect(info.routes[key], `routes.${key}`).toBeUndefined(); + } + for (const key of ['files', 'ai', 'notifications', 'i18n'] as const) { + expect(info.features[key], `features.${key}`).toBe(false); + } + for (const key of ['file-storage', 'automation', 'notification', 'ai', 'i18n'] as const) { + expect(info.services[key].enabled, `services.${key}.enabled`).toBe(true); + expect(info.services[key].status, `services.${key}.status`).toBe('stub'); + expect(info.services[key].handlerReady, `services.${key}.handlerReady`).toBe(false); + expect(info.services[key].route, `services.${key}.route`).toBeUndefined(); + } + }); + + it('keeps advertising routes/features for degraded slots that really serve', async () => { + const degradeds: Record = { + 'file-storage': degraded({ upload: vi.fn() }), + automation: degraded({ listFlows: vi.fn() }), + notification: degraded({ listInbox: vi.fn() }), + ai: degraded({ chat: vi.fn() }), + i18n: degraded({ getLocales: vi.fn().mockReturnValue(['en']), getDefaultLocale: vi.fn().mockReturnValue('en') }), + }; + (kernel as any).getService = vi.fn().mockImplementation((n: string) => degradeds[n] ?? null); + (kernel as any).services = new Map(Object.entries(degradeds)); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.routes.storage).toBe('/api/v1/storage'); + expect(info.routes.automation).toBe('/api/v1/automation'); + expect(info.routes.notifications).toBe('/api/v1/notifications'); + expect(info.routes.ai).toBe('/api/v1/ai'); + expect(info.routes.i18n).toBe('/api/v1/i18n'); + expect(info.features.files).toBe(true); + expect(info.features.i18n).toBe(true); + expect(info.services['file-storage'].status).toBe('degraded'); + expect(info.services['file-storage'].handlerReady).toBe(true); + }); + }); + // ═══════════════════════════════════════════════════════════════ // i18n across server/dev/mock environments // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index f3d0267a91..6386b4931e 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -11,7 +11,8 @@ import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js'; import * as actionExec from './action-execution.js'; -import { createAnalyticsDomain, handleAnalyticsRequest, isAnalyticsServiceServeable } from './domains/analytics.js'; +import { createAnalyticsDomain, handleAnalyticsRequest } from './domains/analytics.js'; +import { isServiceServeable } from './service-serveable.js'; import { createI18nDomain, handleI18nRequest } from './domains/i18n.js'; import { createNotificationsDomain, handleNotificationRequest } from './domains/notifications.js'; import { createSecurityDomain, handleSecurityRequest } from './domains/security.js'; @@ -863,26 +864,37 @@ export class HttpDispatcher { const hasAuth = !!authSvc; const hasSearch = !!searchSvc; - const hasFiles = !!filesSvc; - // [#4000] Mirrors the analytics domain's OWN guard - // (`isAnalyticsServiceServeable`, domains/analytics.ts): a slot filled - // by a self-declared stub takes the same `handled:false` → 404 an empty - // slot takes, so advertising the route on mere presence would promise an - // API that can only 404 — the `declared ≠ enforced` failure `hasMcp` - // below refuses by construction. Same predicate ⇒ same answer. + // [#4000, #4058] Every service whose HTTP surface is a DISPATCHER DOMAIN + // mirrors that domain's OWN guard (`isServiceServeable`, + // service-serveable.ts): a slot filled by a self-declared non-handler + // takes the same 404/501 an empty slot takes, so advertising the route + // on mere presence would promise an API that can only fail — the + // `declared ≠ enforced` failure `hasMcp` below refuses by construction. + // Same predicate ⇒ same answer. #4000 did this for `analytics` alone; + // #4058 extended it to the rest of the dispatcher-owned domains. // - // `analyticsRegistered` stays presence-only, for the services map alone: - // a registered stub is reported there as `status: 'stub', - // handlerReady: false` (D12's honest self-report), which says strictly - // more than collapsing it to `unavailable` / "install a plugin" would. - const analyticsRegistered = !!analyticsSvc; - const hasAnalytics = isAnalyticsServiceServeable(analyticsSvc); + // A `degraded` implementation keeps its route: `handlerReady` defaults + // to `true` for it and its domain keeps serving it (that is the whole + // point of the `stub` / `degraded` split — see plugin-dev's markers). + // + // `*Registered` stays presence-only, for the `services` map alone: a + // registered stub is reported there as `status: 'stub', handlerReady: + // false` (D12's honest self-report), which says strictly more than + // collapsing it to `unavailable` / "install a plugin" would. + const analyticsRegistered = !!analyticsSvc; + const filesRegistered = !!filesSvc; + const aiRegistered = !!aiSvc; + const notificationRegistered = !!notificationSvc; + const i18nRegistered = !!i18nSvc; + const automationRegistered = !!automationSvc; + const hasFiles = isServiceServeable(filesSvc); + const hasAnalytics = isServiceServeable(analyticsSvc); const hasWorkflow = !!workflowSvc; - const hasAi = !!aiSvc; - const hasNotification = !!notificationSvc; - const hasI18n = !!i18nSvc; + const hasAi = isServiceServeable(aiSvc); + const hasNotification = isServiceServeable(notificationSvc); + const hasI18n = isServiceServeable(i18nSvc); const hasUi = !!uiSvc; - const hasAutomation = !!automationSvc; + const hasAutomation = isServiceServeable(automationSvc); const hasCache = !!cacheSvc; const hasQueue = !!queueSvc; const hasJob = !!jobSvc; @@ -998,10 +1010,12 @@ export class HttpDispatcher { data: svcAvailable(routes.data, 'kernel'), // Plugin-provided — only available when a plugin registers the service auth: hasAuth ? svcAvailable(routes.auth, undefined, authSvc) : svcUnavailable('auth'), - automation: hasAutomation ? svcAvailable(routes.automation, undefined, automationSvc) : svcUnavailable('automation'), - // [#4000] Presence-gated, not serveability-gated: a registered - // stub self-reports as `stub` / `handlerReady: false` here (and - // carries no `route`, since `routes.analytics` is undefined for it). + // [#4000, #4058] Presence-gated, not serveability-gated: a + // registered stub self-reports as `stub` / `handlerReady: false` + // here (and carries no `route`, since the matching `routes.*` + // entry is undefined for it). Collapsing it to `unavailable` / + // "install a plugin" would say strictly less. + automation: automationRegistered ? svcAvailable(routes.automation, undefined, automationSvc) : svcUnavailable('automation'), analytics: analyticsRegistered ? svcAvailable(routes.analytics, undefined, analyticsSvc) : svcUnavailable('analytics'), cache: hasCache ? svcAvailable(undefined, undefined, cacheSvc) : svcUnavailable('cache'), queue: hasQueue ? svcAvailable(undefined, undefined, queueSvc) : svcUnavailable('queue'), @@ -1020,10 +1034,11 @@ export class HttpDispatcher { message: realtimeSelf?.message ?? 'In-process event bus only — no HTTP/WS realtime surface is mounted', } : svcUnavailable('realtime'), - notification: hasNotification ? svcAvailable(routes.notifications, undefined, notificationSvc) : svcUnavailable('notification'), - ai: hasAi ? svcAvailable(routes.ai, undefined, aiSvc) : svcUnavailable('ai'), - i18n: hasI18n ? svcAvailable(routes.i18n, undefined, i18nSvc) : svcUnavailable('i18n'), - 'file-storage': hasFiles ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('file-storage'), + // Presence-gated for the same reason `analytics` is (#4058). + notification: notificationRegistered ? svcAvailable(routes.notifications, undefined, notificationSvc) : svcUnavailable('notification'), + ai: aiRegistered ? svcAvailable(routes.ai, undefined, aiSvc) : svcUnavailable('ai'), + i18n: i18nRegistered ? svcAvailable(routes.i18n, undefined, i18nSvc) : svcUnavailable('i18n'), + 'file-storage': filesRegistered ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('file-storage'), search: hasSearch ? svcAvailable(undefined, undefined, searchSvc) : svcUnavailable('search'), }, locale, diff --git a/packages/runtime/src/service-serveable.ts b/packages/runtime/src/service-serveable.ts new file mode 100644 index 0000000000..5d0613ae26 --- /dev/null +++ b/packages/runtime/src/service-serveable.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ONE predicate for "may the dispatcher call the thing in this service slot + * like a real implementation?" — ADR-0076 D12 conclusion 3, applied across the + * service domains (#4058). + * + * D12's third conclusion binds *consumers*: only `handlerReady: true` counts as + * a real capability. Discovery honoured that from #3028 on; the dispatcher — a + * consumer too — gated every domain on mere slot occupancy, so anything + * registered under a canonical name got called and its fabricated results went + * back as a 200. #4000 enforced the rule for `analytics` alone + * (`isAnalyticsServiceServeable`, since folded into this module) because the + * class-wide adoption needed a per-domain decision; #4058 made it and this is + * where the answer lives. + * + * `handlerReady` (not `status`) is the test, and that distinction is what makes + * the rule safe to apply everywhere: an implementation that self-declares + * `degraded` genuinely serves requests — `handlerReady` defaults to `true` for + * it — and keeps serving. Only a self-confessed non-handler (`handlerReady: + * false`, which is the default for `status: 'stub'`) is answered as if the slot + * were empty. A working in-memory fallback is therefore NOT collateral damage: + * it is `degraded`, and it stays on the wire. + * + * Read by the service domains, the route-mount gate, and discovery's + * `routes`/`features` — one predicate, so what is advertised and what is served + * cannot disagree. + */ + +import { readServiceSelfInfo } from '@objectstack/spec/api'; + +/** + * True when `svc` occupies its slot AND does not self-declare as a non-handler. + * + * An empty slot and a slot filled by a self-declared stub are the same amount + * of capability, so callers answer both the same way — whatever their empty-slot + * exit already is (`handled: false` → 404, or an explicit 501). + */ +export function isServiceServeable(svc: unknown): boolean { + if (!svc) return false; + return readServiceSelfInfo(svc)?.handlerReady !== false; +}