diff --git a/.changeset/dispatcher-search-capability-stated-false.md b/.changeset/dispatcher-search-capability-stated-false.md new file mode 100644 index 0000000000..a8647b208d --- /dev/null +++ b/.changeset/dispatcher-search-capability-stated-false.md @@ -0,0 +1,32 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): the dispatcher's `capabilities.search` answers a stated `false` instead of bare slot presence (#7602) + +`getDiscoveryInfo()` — the runtime dispatcher's discovery face — derived +`capabilities.search.enabled` from `!!searchSvc`, i.e. whether the `search` +service slot happened to be filled. That answer is `false` on every host that +exists today, but only by coincidence: nothing in the platform registers the +slot (`CORE_SERVICE_PROVIDER` records `'search': null`). + +The dispatcher mounts **no `/search` route** — no `route-ledger.ts` entry, no +handler, no `routes.search` — so slot presence was never the right predicate +for it. Register any `ISearchService`, which is exactly what the slot exists +for (`CoreServiceName`: "Search Engine (Elastic/Meili)"), and the discovery +document flipped `search.enabled` to `true` while the host still 404s the +endpoint — an advertised endpoint that cannot be called, the same +`declared ≠ enforced` defect #7541 closed on the `getDiscovery()` producer. + +`capabilities.search` is now a hardcoded `false` carrying its reasoning inline, +the way `websockets` immediately below it already is (ADR-0076 D12, #2462). +The dispatcher's own doctrine at that site says a service whose HTTP surface is +a *dispatcher domain* mirrors that domain's guard — "same predicate ⇒ same +answer" (#4000 / #4058) — and `search` is not a dispatcher domain, so it has no +guard to mirror. Should a `/search` domain ever be mounted, the guard applies +and the key is re-derived from it. + +No wire change on any existing host: the emitted value is `false` before and +after. What changes is that it stays `false` when the slot is filled. The +`services.search` entry is unaffected — it reports what is *registered*, not +what is served, and already advertised no route. diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts index f97db506f6..0d98c901c6 100644 --- a/packages/runtime/src/discovery-schema-conformance.test.ts +++ b/packages/runtime/src/discovery-schema-conformance.test.ts @@ -235,6 +235,60 @@ describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { const bare: any = await new HttpDispatcher(withoutComments).getDiscoveryInfo('/api/v1'); expect(bare.capabilities.comments.enabled).toBe(false); }); + + it('[#7602] answers `search` false WITH a search service registered — the slot is not the predicate', async () => { + // The `comments` pair above proves a key is measured rather than stamped. + // This is the opposite direction, and the only assertion that separates + // "right today" from "right on purpose". + // + // `capabilities.search.enabled` read `!!searchSvc` until #7602. That is + // `false` on every host that exists — nothing registers the slot + // (`CORE_SERVICE_PROVIDER` records `'search': null`) — so the pin below + // (`has retired features and endpoints`) passed for a reason that had + // nothing to do with what this face serves. Fill the slot, which is + // exactly what it exists for, and the document flipped to `true` while + // the dispatcher still mounts no `/search` route: an advertised endpoint + // that 404s (Prime Directive #10), the same defect #7541 closed on the + // `getDiscovery()` producer. + // + // Reverse verification, direction predicted BEFORE running (and observed: + // 1 failed / 22 passed): restore the old slot-presence predicate — + // `search: { enabled: searchRegistered }` — and THIS case goes red while the + // pre-existing `enabled === false` pin — driven by a kernel with no + // services — stays green. That asymmetry is the whole point of the case. + const kernel = { + context: { + getService: (name: string) => { + if (name === 'objectql') { + return { + registry: { + getObject: vi.fn().mockReturnValue({ name: 'test_obj' }), + getRegisteredTypes: vi.fn().mockReturnValue([]), + getAllPackages: vi.fn().mockReturnValue([]), + }, + }; + } + // A real-shaped occupant of the slot — `CoreServiceName`'s + // "Search Engine (Elastic/Meili)", not a stub that `isServiceServeable` + // would reject anyway. Nothing about it can make this face serve + // `/search`, which is the point. + if (name === 'search') return { searchAll: async () => [] }; + return null; + }, + }, + } as any; + + const info: any = await new HttpDispatcher(kernel).getDiscoveryInfo('/api/v1'); + + // Anti-vacuity: the stub really WAS resolved, so the `false` below is a + // decision about the HTTP surface and not a failed registration. + expect(info.services.search.enabled, 'the search slot must actually be filled in this fixture').toBe(true); + + expect(info.capabilities.search.enabled, 'a filled `search` slot must NOT advertise the capability').toBe(false); + // …and no route is advertised on its behalf either, on the same basis. + expect(info.routes.search, '`routes.search` must stay unadvertised').toBeUndefined(); + expect(DiscoverySchema.safeParse(info).success).toBe(true); + }); }); it('has retired `features` and `endpoints` (ADR-0049 enforce-or-remove)', async () => { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 0124bfc4c6..6508c8ee01 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1108,7 +1108,6 @@ export class HttpDispatcher { ]); const hasAuth = !!authSvc; - const hasSearch = !!searchSvc; // [#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 @@ -1134,7 +1133,13 @@ export class HttpDispatcher { // 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. + // + // [#7602] `search` is the one slot that is ONLY ever read this way. It + // has no dispatcher domain to mirror and no capability to gate (see + // `capabilities.search` below), so presence — "is the slot filled" — + // is the whole of what this producer honestly knows about it. const analyticsRegistered = !!analyticsSvc; + const searchRegistered = !!searchSvc; const filesRegistered = !!filesSvc; const aiRegistered = !!aiSvc; const notificationRegistered = !!notificationSvc; @@ -1369,7 +1374,33 @@ export class HttpDispatcher { // host does not deliver it". The six additions below are the other // producer's half, answered from THIS producer's own facts. capabilities: { - search: { enabled: hasSearch }, + // [#7602] Stated `false`, not slot-gated. This dispatcher + // mounts NO `/search` route — no `route-ledger.ts` entry, no + // handler, no `routes.search` above — so this face cannot + // deliver search no matter what fills the `search` slot. + // + // It used to read `!!searchSvc`, which was right only by + // accident: `false` because nothing registers the slot + // (`CORE_SERVICE_PROVIDER` records `'search': null`). Register + // any `ISearchService` — precisely what the slot exists for, + // per `CoreServiceName`'s "Search Engine (Elastic/Meili)" — + // and this document flipped to `true` while the host still + // 404s the endpoint, the `declared ≠ enforced` failure #7541 + // closed on the `getDiscovery()` producer. + // + // Slot presence was never the right predicate here: `search` + // is not a dispatcher domain, so the "same predicate ⇒ same + // answer" rule above (#4000 / #4058) has no domain guard to + // mirror. Until the dispatcher serves search, the honest + // answer is a `false` that carries its reasoning — exactly how + // `websockets` is answered immediately below (ADR-0076 D12, + // #2462). The slot itself is still reported, honestly and + // separately, as `services.search`. + // + // Re-derive this ONLY when a `/search` domain is actually + // mounted; then the guard applies and the question answers + // itself (#7602 option 2). + search: { enabled: false }, // No WS/HTTP realtime surface is mounted anywhere — a mere // in-process realtime service must not advertise websockets // (ADR-0076 D12, #2462). @@ -1530,7 +1561,13 @@ export class HttpDispatcher { 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'), + // [#7602] Presence-gated, and correctly so: this map reports + // what is REGISTERED, not what is served. The `route` argument + // is already `undefined` — no `/search` is advertised on the + // occupant's behalf — which is the same fact `capabilities + // .search` states above, said about the slot instead of about + // the host's HTTP surface. + search: searchRegistered ? svcAvailable(undefined, undefined, searchSvc) : svcUnavailable('search'), }, locale, };