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
32 changes: 32 additions & 0 deletions .changeset/dispatcher-search-capability-stated-false.md
Original file line numberDiff line numberDiff line change
@@ -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.
54 changes: 54 additions & 0 deletions packages/runtime/src/discovery-schema-conformance.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand Down
43 changes: 40 additions & 3 deletions packages/runtime/src/http-dispatcher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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;
Expand DownExpand Up@@ -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).
Expand DownExpand Up@@ -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,
};
Expand Down
Loading