Federated Search in the Site Frontend — Design
TL;DR. Yes, we can give human visitors federated search, reusing the MCP fan-out that agents already get. Recommended UX: keep local search exactly as it is, and add an explicit "Also search N connected bases" expander under the local results (progressive disclosure). Federated results render as per-base sections, each with a base badge (favicon + host), plain-text snippets, and absolute deep links to the peer's own site. A status line shows "N of M bases answered" and names the bases that timed out (fail-loud). Backend: extract the existing MCP fan-out into a shared internal/fedsearch service package and expose it as a federatedSearch GraphQL query — no cross-base rerank in v1, per-peer order is trusted.
Status: design only, not built. Written 2026-07-11.
1. Current state
Local site search (what visitors have today)
- The search UI is a $mol island injected by the default template:
internal/defaulttemplate/views.html:421-423mounts$trip2g_user_search. - The widget (
assets/ui/user/search/search.view.ts:2-14) fires asearch(input: {query})GraphQL query, debounced 1s, min 3 chars (search.view.ts:56-76). Results render as an overlay list with highlighted title/content, score, and a "read more" link (search.view.tree:38-59). - Backend:
internal/case/sitesearch/resolve.go:50— bleve text lane + optional vector lane, RRF-fused (resolve.go:290), optional cross-encoder rerank (resolve.go:350), then per-result permission filtering. Local only — no federation anywhere in this path. - GraphQL surface:
SearchInput { query }→SearchConnection { nodes: [SearchResult] }withhighlightedTitle,highlightedContent,url,score,matchOrigin(internal/graph/schema.graphqls:1422-1447).
Federation (what agents have, MCP only)
- Peers are declared as KB-notes: any note with
mcp_federation_kb_url(+ optionalmcp_federation_kb_id) frontmatter (internal/model/mcp_federation_note.go:16-24). The hub's router notes live indocs/en/hub/*.md. The registry is justLatestNoteViews().MCPFederationNotes, ACL-filtered per requester viaaccessibleKBNotes(internal/case/mcp/federation_acl.go:15). federated_search(MCP tool,internal/case/mcp/federation_handlers.go:11) fans outsearchcalls to all accessible KBs. Onmain(PR #169) the fan-out is bounded:errgroup.SetLimit(concurrency)+ per-peer timeout with a slot-releasing goroutine (internal/case/mcp/federation.go, main:fanout/callPeer), and blind fan-outs are capped (capBlindFanout(selected, env.FederatedFanoutLimit()), mainfederation_handlers.go:36). Defaults: concurrency 5, limit 7 peers, 5s per-peer timeout (internal/appconfig/config.go:527-531on main).- Results are aggregated with per-KB attribution and per-KB errors —
FederatedCallPayload { results: [{kb_id, result, latency}], errors: [{kb_id, error}] }(internal/case/mcp/types.go:130-145,federation.go:103). Partial results are already first-class: a response is only an error if every peer failed. - Each peer answers its own
searchtool (internal/case/mcp/resolve.go:460) — the same hybrid lanes + permission filter as site search — and returnsSearchResultPayloadwithtitle,note_path, absoluteurl,score,matches[].snippet(types.go:147-163). So deep links to the peer's own site come back ready to use. - Transport:
internal/federation/client.go— JSON-RPC over HTTP, SSRF-safe dialing, optional HMAC-signed JWT,X-MCP-Federation-Depthheader. The fan-out calls the peer's plainSearch(notFederatedSearch), so there is no recursive cascade — one hop only. Anonymous peers see only their public notes (peer-sidefilterSearchResults,resolve.go:531).
The gap: all of this is reachable only through the MCP endpoint. GraphQL has no federated query, and the template widget knows nothing about peers.
2. UX design
Recommended pattern: progressive disclosure + grouped-by-base sections
Weighing the four options from the brief:
| Option | Verdict |
|---|---|
| (a) merged list, per-result KB badge | Attribution is right, but merging requires comparable ranking across bases (scores aren't comparable — different corpora, different lanes) and fights progressive arrival: late peers reshuffle the list under the user's cursor. |
| (b) grouped-by-base sections | Maps 1:1 onto how results actually arrive (per-peer, partial, some timing out). Each section is independently appendable. Attribution is structural, not a decoration. |
| (c) local-first + explicit "search the federation" expander | Solves the latency and privacy problems at the root: the slow, query-leaking fan-out never runs unless the visitor asks. Local search stays instant-as-you-type. |
| (d) kb picker / filter chips | Useful, but a power-user refinement — not the entry point. |
Recommendation: (c) as the trigger, (b) as the presentation, with (a)'s badge anatomy inside each section. Concretely:
- Local search behaves exactly as today (debounced live overlay).
- When the site has ≥1 accessible KB-note, a row appears under the local results:
🌐 Also search 7 connected bases — your query will be sent to those sites.
One explicit click (or Enter on it) fires the fan-out. Never fired per-keystroke. - While running: a status line "Searching connected bases… 2 of 7 answered" + one skeleton block.
- Done: per-base sections appended below local results, plus a fail-loud footer when partial:
⚠ 5 of 7 bases answered. No answer from nietzsche.2pub.me (timeout), laozi.2pub.me (error).
Why this wins: it matches the fan-out's real behavior (bounded, partial, 0–5s per peer) instead of hiding it; it matches the attribution-first research finding (source identity is the value — a Nietzsche hit and a Rockefeller hit for the same query mean different things); and it keeps the default path fast and private.
Result-item anatomy
┌───────────────────────────────────────────────────────────┐
│ ▸ [favicon] Ницше · nietzsche.2pub.me (kb_id) │ ← section header, once per base
│───────────────────────────────────────────────────────────│
│ Title of the note │ ← link = peer's absolute url
│ …plain-text snippet from matches[0].snippet… │
│ nietzsche.2pub.me/notes/will-to-power ↗ │ ← visible host, external-link affordance
└───────────────────────────────────────────────────────────┘
- Section badge = KB-note title (the hub already curates human names in
docs/en/hub/*.md) + host from the result URL + favicon (https://<host>/favicon.ico, lazy<img>with fallback glyph).kb_idshown small/secondary — it's an agent-facing identifier. - Title: plain text from
SearchResultItem.Title. No<mark>highlighting — peers return plain snippets, and we must not innerHTML remote content anyway (see Risks). - Snippet:
matches[0].snippet, rendered as text, never HTML. - Link:
SearchResultItem.URL— already absolute to the peer's site. Open in the same tab (it's still "the federation", not an ad);↗marks it as leaving the current site. - No score display. Scores aren't comparable across bases; showing "3/10" from another corpus misleads. Per-base order is the peer's own ranking — keep it.
- Cap each section at ~5 results with a "more on this site ↗" link to the peer's own search (later; v1 just caps).
Latency and partial results
- Fan-out worst case = per-peer timeout (5s default). The UI must not look frozen: show the "N of M answered" line immediately (M is known up front from the KB registry) and a skeleton.
- v1: one request, one response. The bounded fan-out already returns everything (successes + per-KB errors) in a single payload after at most ~5s. The status line during the wait is a simple spinner + "searching M bases…"; true incremental arrival is deferred.
- Later: streaming. The stack already does GraphQL subscriptions over SSE (
docs/dev/gqlgen_fasthttp.md); afederatedSearchStreamsubscription can emit one event per peer asfanoutcompletes each slot, and sections appear one by one. That's an additive change — same service, different transport. - Degradation is explicit, never silent: timed-out/errored bases are named in the footer with a retry affordance ("try again" re-runs the fan-out for failed KBs only — later; v1 re-runs all). If all peers fail: "No connected base answered. Try again." If federation isn't configured, the expander simply doesn't render.
3. Backend shape
Extract a shared service: internal/fedsearch
Today the fan-out logic (registry read + ACL + cap + bounded fan-out + per-KB aggregation) lives inside internal/case/mcp. Duplicating it in a GraphQL resolver would fork the bounding policy. Instead, per the service-package pattern (docs/dev/app_patterns.md:26-72):
// internal/fedsearch/fedsearch.go
type Env interface {
LatestNoteViews() *model.NoteViews
CanReadNote(ctx context.Context, note *model.NoteView) (bool, error)
FederationClient(ctx context.Context, kbID string) (model.Federation, error)
FederatedFanoutConcurrency() int
FederatedFanoutLimit() int
FederatedFanoutTimeout() time.Duration
}
type FedSearch struct{ env Env }
// KBs returns the ACL-filtered registry (for the UI's "N connected bases" count).
func (f *FedSearch) KBs(ctx context.Context) ([]*model.MCPFederationNote, error)
// Search fans out query to the given (or all) KBs; always returns partial results.
func (f *FedSearch) Search(ctx context.Context, query string, kbIDs []string) ([]KBResult, error)
type KBResult struct {
KB *model.MCPFederationNote
Status Status // OK | ERROR | TIMEOUT | SKIPPED (capped)
Latency time.Duration
Results []model.FederatedSearchItem // parsed from peer StructuredContent
Err string
}
- Move
fanout/callPeer(maininternal/case/mcp/federation.go),selectFederationKBs,capBlindFanout, andaccessibleKBNotes(federation_acl.go:15) into the package.internal/case/mcpkeeps only MCP payload formatting (aggregateFederationResults,federation.go:103) on top of[]KBResult. - The service parses the peer's
StructuredContentinto typedSearchResultItems (internal/case/mcp/types.go:152-163) once, in one place. The MCP handler re-serializes; the GraphQL resolver maps to models. Unparseable structured content →Status: ERRORfor that KB (fail-loud, not silent empty). - Embed anonymously in
app(*fedsearch.FedSearch), compile-timevar _ fedsearch.Env = (*app)(nil). No proxy methods.
GraphQL surface
type FederationKB { id: String!, title: String!, host: String! }
input FederatedSearchInput { query: String!, kbIds: [String!] }
enum FederatedKBStatus { OK, ERROR, TIMEOUT, SKIPPED }
type FederatedKBResult {
kb: FederationKB!
status: FederatedKBStatus!
latencyMs: Int!
results: [FederatedSearchResult!]! # empty unless OK
error: String # human-readable, fail-loud
}
type FederatedSearchResult {
title: String!
url: String! # absolute, peer's own site
snippet: String # plain text
}
extend type Query {
federationKbs: [FederationKB!]! # drives the expander + "M bases"
federatedSearch(input: FederatedSearchInput!): [FederatedKBResult!]!
}
Resolvers stay thin (app_patterns.md:173-189): delegate to fedsearch.Search. Per-KB failures are data (status/error fields), not GraphQL errors — the partial-results contract carries through to the client. A whole-request error is reserved for infrastructure failure.
Frontend
Extend $trip2g_user_search_panel: after local results, query federationKbs (cheap, local, cached per session); if non-empty, render the expander row. Click → federatedSearch request via $trip2g_graphql_request → per-KB Section* sub-views. Standard view.tree + view.ts split, localized strings via @ markers (assets/ui/user/search/search.view.tree conventions).
The rerank question
Should we rerank across merged federated results locally? v1: no. Trust per-peer order, don't merge.
- Cross-base raw scores are incomparable (different corpora, different lanes, different reranker configs) — that's why the design groups instead of merging.
- RRF over per-peer ranks would be a legitimate merge (rank-only, no score normalization — same trick as
sitesearch/resolve.go:287-290), but it still buries source identity, which the grouped UX treats as the point. - A genuinely comparable merged list needs a local cross-encoder pass over
(query, snippet)for all federated results —reranker.BlendRRFmachinery exists (internal/case/sitesearch/resolve.go:350-358), snippets are window-sized-ish, so it's feasible. But it adds a serial rerank hop after the slowest peer, and the reranker is optional infrastructure (and was removed from the default deployment as measured-worse for local search). Park it as a possible "merged view" toggle later, gated onFeatures().VectorSearch.Reranker.Enabled.
4. Scope: v1 vs later
v1 (minimal):
internal/fedsearchservice package extracted frominternal/case/mcp(pure refactor + one new parse step); MCP handlers rewired on top — behavior-identical, existing MCP tests keep passing.federationKbs+federatedSearchGraphQL queries (public, same visibility rules as MCP anonymous access).- Search panel: expander row with privacy hint → single fan-out request → grouped per-base sections, plain-text snippets, absolute links, "N of M answered" + named failures footer.
- Per-IP rate limit on
federatedSearch(see Risks).
Later:
- Per-KB filter chips / picker (option d) and per-section "retry this base".
- SSE streaming (
federatedSearchStream) for true progressive arrival. - Optional cross-encoder merged view; "federated similar" on note pages.
- Short-TTL response cache keyed
(query, kbID). - Peer favicon/name caching in the KB registry instead of live
<img>fetches.
5. Risks
- Privacy — query leakage (the main one). Every federated search sends the visitor's query string to up to 7 third-party servers, with the hub's federation identity attached. Agents opted into this; a human typing into "the site's search box" has not. Mitigations: fan-out only on explicit click; the expander row itself states "your query will be sent to N connected sites"; never auto-federate as-you-type; document it in the hub notes.
- Abuse / amplification. One anonymous HTTP request triggers up to 7 outbound requests — a cheap traffic amplifier against peers and against our own egress. Mitigations: existing bounds (concurrency 5 / cap 7 / 5s timeout) + per-IP rate limit on the GraphQL query (e.g. a few fan-outs per minute; the pattern of
signinCounter,app_patterns.md:74-90) + min query length 3 (same as local). - Untrusted remote content. Peer snippets/titles must be rendered as text, never innerHTML (the local
$trip2g_user_search_dimmerpath innerHTMLs highlighted content — federated results must not reuse it). URLs come from the peer; render as-is inhrefbut they're already the peer's own domain by construction (KB-note declares the endpoint; SSRF-safe dialing ininternal/federation/client.go:39). - Latency perception. 5s worst case behind one click is acceptable only with the progress line and named-failure footer; without them it reads as broken.
- Registry drift. Dead peers make the feature look bad ("2 of 7 answered" forever). The fail-loud footer doubles as the operator's signal to prune KB-notes; later, surface per-KB error rates in the admin metrics that
metrics.goalready records.
References
internal/case/sitesearch/resolve.go:50,287,350— local hybrid search, RRF, rerank blendassets/ui/user/search/search.view.ts:2,56— search widget, GraphQL query, debounceinternal/defaulttemplate/views.html:421— widget mount point in the templateinternal/graph/schema.graphqls:1422-1447— current search GraphQL typesinternal/case/mcp/federation_handlers.go:11—federated_searchMCP handler (main: cap + bounded fanout at :36-38)internal/case/mcp/federation.go(main) —fanout/callPeer,aggregateFederationResultsinternal/case/mcp/federation_acl.go:15— ACL-filtered KB registryinternal/case/mcp/types.go:130-163— per-KB payloads,SearchResultItem(absoluteurl)internal/model/mcp_federation_note.go:16— KB-note frontmatter extractioninternal/federation/client.go:33,49— peer client, timeouts, SSRF-safe, HMAC authinternal/appconfig/config.go:527-531(main) — fan-out defaults: concurrency 5, limit 7, 5sdocs/dev/app_patterns.md:26,173— service-package + thin-resolver rules