Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): dispatcher capabilities.search answers a stated false, not bare slot presence (#7602) - #7937
Conversation
…e`, not bare slot presence (#7602) `getDiscoveryInfo()` derived `capabilities.search.enabled` from `!!searchSvc`. That answer is `false` on every host that exists — but only by coincidence: nothing registers the `search` slot (`CORE_SERVICE_PROVIDER` records `'search': null`). The dispatcher mounts no `/search` route at all — no `route-ledger.ts` entry, no handler, no `routes.search` — so slot presence was never the right predicate. Register any `ISearchService`, exactly what the slot exists for, and the discovery document flipped to `true` over an endpoint that still 404s: the `declared ≠ enforced` defect #7541 closed on the `getDiscovery()` producer, waiting on a precondition nobody had met yet. `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); `search` is not a dispatcher domain, which is precisely why it had no guard to mirror. This replaces a coincidence with a reason. `services.search` keeps its presence gate — that map reports what is REGISTERED, not what is served, and it already advertised no route. `hasSearch` is renamed `searchRegistered` and moved into the documented `*Registered` group so the two facts stop sharing one name. Coverage: `discovery-schema-conformance.test.ts`'s existing pin (`capabilities.search.enabled === false`) is untouched and still green, but it passes for the accidental reason. A new case asserts `false` WITH a search service registered, anti-vacuity-checked against `services.search.enabled === true` so the fixture is proven to fill the slot. Reverse-verified: with the producer change reverted, that one case goes red (1 failed / 22 passed) while the pre-existing pin stays green. 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeW3KzpmdURCJ32CNTJdM7
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7602
What was wrong
getDiscoveryInfo()— the runtime dispatcher's discovery face — derivedcapabilities.search.enabledfromhasSearch = !!searchSvc, i.e. bare service-slot presence.The defect is not that today's answer is wrong — it is that today's answer is right by accident.
falseis produced only because nothing fills the slot (CORE_SERVICE_PROVIDERrecords'search': nullon every host in either repo). Register anyISearchService— which is exactly what the slot exists for, perCoreServiceName's "Search Engine (Elastic/Meili)" — and the discovery document flipssearch.enabledtotruewhile the host still 404s the endpoint. That is thedeclared ≠ enforcedfailure #7541 closed on thegetDiscovery()producer, merely waiting on a precondition nobody has met yet.Premise, verified on
origin/main@7372d46before codingLine numbers had drifted from the card's
~L1111/~L1372; located by text./searchroutesearchentry inroute-ledger.ts, no handler inhttp-dispatcher.ts, noroutes.searchkey in the emittedroutesliteralcapabilities.searchstill slot-derivedhttp-dispatcher.ts:1111const hasSearch = !!searchSvc;→:1372search: { enabled: hasSearch }The card's option 2 (mount a
/searchdispatcher domain) is deliberately deferred, not overlooked: no route exists and no host registers the slot, so mounting one would be building an unrequested capability.The change
capabilities.searchis now a hardcodedfalsecarrying its reasoning inline, exactly the waywebsocketsimmediately below it already is (ADR-0076 D12 / #2462).The dispatcher's own doctrine at that site says every service whose HTTP surface is a dispatcher domain mirrors that domain's guard — "same predicate ⇒ same answer" (#4000 / #4058).
searchis not a dispatcher domain, which is precisely why slot presence was never the right predicate for it and why it has no guard to mirror. Should a/searchdomain ever be mounted, the guard applies and the key is re-derived from it — the comment says so.Also folds in #5672's Ruling A where it applies: every key from every producer, answered from that producer's own facts. Two producers answering one key by two unrelated predicates was legal only while both answers happened to be honest — this ends that state for
search.services.searchis untouched in behaviour, on purposeThe third read the triage comment flagged (
search: hasSearch ? svcAvailable(…) : svcUnavailable('search')) stays presence-gated, and correctly so: that map reports what is registered, not what is served — the documented*Registeredcontract at the same site — and it already passesroute: undefined, advertising nothing on the occupant's behalf.hasSearchis renamedsearchRegisteredand moved into the*Registeredgroup so the two different facts stop sharing one name.Testing
packages/runtime/src/discovery-schema-conformance.test.ts's existing pin (info.capabilities.search.enabled === false) is not deleted, skipped or weakened — it was correct before this PR and is still green. But it passes for the accidental reason, so this PR adds the case that gives it meaning:[#7602] answers 'search' false WITH a search service registered — the slot is not the predicate. It resolves a real-shapedISearchServiceoccupant, assertscapabilities.search.enabled === false, assertsroutes.searchstays unadvertised, and re-parses the whole body againstDiscoverySchema.services.search.enabled === true, so thefalseabove is provably a decision about the HTTP surface rather than a fixture that failed to register anything.Reverse verification (direction predicted before running): with the producer change reverted to
search: { enabled: searchRegistered }, the run is 1 failed / 22 passed — the new case red, the pre-existing pin green. That asymmetry is the whole point of the case, and without it this change is untestable.Gates run
node scripts/pm/dispatch-gates.mjs …→pnpm check:changeset-gate-self-tests✅packages/runtimesuite: 139 files / 2128 tests passed ✅turbo run build, 70 tasks) ✅pnpm check:type-check-debtagainst the built closure ✅ — "none above its recorded number";@objectstack/runtimemeasures exactly its recorded227, i.e. the new test file added zero type errors and no ledger entry was raised.Wire impact
None on any existing host: the emitted value is
falsebefore and after. What changes is that it staysfalsewhen the slot is filled.Out of scope, filed separately
services.searchreportshandlerReady: truefor a filled slot with no mounted handler — the same contradiction #4318 fixed forcache/queue/jobviasvcInProcess(handlerReady: false), left latent on the one slot nobody fills. Not touched here; filed as a finding.Generated by Claude Code