Extend query endpoint to support multi-project search. When `project` param is omitted, a single query searches all projects concurrently, deduplicates results, and merges scores.
## Design
**Single-project query (no change):**
```
GET /memory/query?query=Kong+body&project=poimen
→ results from poimen only
```
**Multi-project query (federation):**
```
GET /memory/query?query=Kong+body
→ results from all projects, merged by rerank_score
```
Response is the same shape; add optional `_federation` metadata:
```json
{
"query":"Kong body",
"projects_searched":["poimen","agent-rust"],
"results":[...],
"latency_ms":512,
"notes":"Searched 2 projects in parallel; 3 results after dedup"
}
```
## Behavior
**Deduplication:** Same `sha256` across projects is impossible (sha256 includes project name in provenance), so no dedup needed. If two projects happen to have identical text:
- Treat as separate nodes (different projects, different provenance)
- Return both in results (may both rank high)
- Ensure test coverage catches this edge case
**Concurrency:** Query all projects in parallel using `tokio::join_all()` or `futures::stream`:
**Merging:** After all projects return, merge result vectors:
- Collect all results from all projects into one vec
- Re-sort by `rerank_score` descending (global order)
- Take top `limit` (e.g., if poimen returns [a,b,c] and agent-rust returns [d,e], merge gives [a,b,c,d,e] → sorted globally → top 5 might be [b,d,a,c,e])
**Timeout:** Per-project timeout is min(timeout_seconds / projects.len(), 2s). If one project is slow, others complete faster and we still return results from fast projects after global timeout.
- E.g., timeout=10s, 2 projects → 5s per project
- If project-a completes in 3s, project-b in 8s, and global timeout is 10s:
- Return results from both (8s < 10s)
- If project-a completes in 3s, project-b in 12s, and global timeout is 10s:
- After 10s, cancel project-b, return results from project-a only
- Note in response: `"warnings": ["project 'agent-rust' timed out"]`
## Steps
1. Parse `project` param:
- If provided, single-project path (M3.5.3 unchanged)
- If omitted, multi-project path
2. List all known projects (from queries YAML):
```rust
let projects = load_standing_queries()?.projects();
```
3. Spawn concurrent query tasks:
```rust
let futures: Vec<_> = projects.into_iter()
.map(|proj| {
let params = params.clone();
params.project = Some(proj);
query_handler_impl(¶ms, store, llm)
})
.collect();
```
4. Race with timeout:
```rust
let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
let results = match tokio::time::timeout_at(deadline, futures::future::join_all(futures)).await {
1. `a1_single_project_no_federation` — GET /query?project=poimen returns single-project results only.
2. `a2_multi_project_searches_all` — GET /query (no project) with >1 project in DB returns results from all.
3. `a3_global_sort_order` — two projects return results, merge sorts by rerank_score globally (not per-project).
4. `a4_federation_metadata_present` — response includes `projects_searched` array with all completed projects.
5. `a5_partial_results_on_timeout` — slow one project (mock 10s delay), set timeout_seconds=2, GET /query returns results from fast project only with warning.
7. `a7_no_project_filter_in_response` — response.project_filter is null or omitted (unlike single-project which sets it).
8. `a8_concurrent_execution` — spy on timing: timestamp project-a query start, project-b query start, both should be ~simultaneous (not sequential).
**Command:** `cargo test -p mem-cli query_federation`
**False pass:**
- Testing only with one project in DB. Federation always "works" if there is nothing to federate.
- Timeout never exercised. Mock a slow project and assert results are partial.
- Per-project sorting instead of global sort. Results look reasonable but violate the contract (should be global top-k).
- Concurrency not verified. Queries can be sequential (slow) and still return correct results; only timing proves concurrency.
## Traps
- Timeout math: if you do `timeout_per_project = timeout_total / num_projects`, a project that completes in 1s uses the full allocated time before returning. Should be `remaining_time = deadline - now()`.
- Partial results: if project-a returns 5 results and project-b times out, you have 5 results but may have wanted 10 (limit=10). Document whether partial results truncate or stay over-limit.
- Clone overhead: cloning `QueryParams` for each project is small; cloning a large result vec is not. Use references/Arc where possible.
- Flatten after join_all: `join_all` returns `Vec<Result>`, must flatten errors (either as partial results or early exit).
---
Background: [DESIGN.md § Distributed API Layer](../DESIGN.md#distributed-api-layer-homelab-frontend)