Federation mode is the multi-repo mode of lain. Run it with:
lain server --config ./repos.yaml --transport http --port 9999
# Open http://localhost:9999 — the Command Center dashboard.A federation knows about N repositories at once and answers org-wide
questions across them: who defines a given function, who calls it,
what other services depend on it, and which repos are degraded right
now. Federation is the headline of lain: a single long-running
process that owns the index, the workspaces, and the MCP tool surface.
flowchart LR
subgraph FED["FederatedIndex"]
MAP["RwLock<HashMap<RepoId, Arc<RepoIndex>>>"]
GB["Arc<dyn GraphBackend><br/>PetgraphBackend<br/>federated_graph.bin"]
RES["resolve_symbol()<br/>symbol_to_repos index<br/>+ backend fallback"]
end
subgraph R1["RepoIndex bytes"]
LSP1["LSP pool"]
PG1["per-repo petgraph"]
FW1["file watcher"]
end
subgraph R2["RepoIndex tokio"]
LSP2["LSP pool"]
PG2["per-repo petgraph"]
FW2["file watcher"]
end
MAP --> R1
MAP --> R2
R1 -->|project_repo| GB
R2 -->|project_repo| GB
RES --> MAP
RES --> GB
GB --> TOOLS["federation_tools<br/>(6 MCP tools)"]
The diagram shows the two-tier model: each RepoIndex runs its own
engines (LSP pool, petgraph, file watcher) on its own clone, and the
FederatedIndex projects each worker's nodes into one global
petgraph via project_repo(). The six federation MCP tools then
read the global graph and bucket results by repo.
This document is the central reference for federation mode. The schema
of the repos.yaml config lives in docs/REPOS_YAML.md;
this doc covers the operating model, the MCP tools, the resolver rules,
performance, and troubleshooting. The on-disk state format and the
petgraph backend live in docs/TECHNICAL.md. Hot-reload
of repos.yaml / workspaces.yaml is documented in
docs/hot-reload.md; the Command Center dashboard is
in docs/command-center.md.
| Mode | Use it when |
|---|---|
Single-workspace (lain server --config ./repos.yaml --workspace <name> with a one-member workspace) |
You want to scope answers to one repo's subgraph. |
Federation (lain server --config repos.yaml) |
You have many repos and want to ask org-wide questions: "which repos define a function named verify_token?", "what does repo A's shared library affect in repo B and C?", "is every repo healthy right now?". |
The federation tools are read-only and do not let you mutate the underlying graphs.
Federation mode is configured by a repos.yaml file and started by
lain server. The schema is documented in
docs/REPOS_YAML.md; this section is the operational
quickstart.
sequenceDiagram
autonumber
participant Op as Operator
participant CLI as lain repos add
participant YAML as repos.yaml
participant Sock as Unix socket
participant Bus as ReloadBus
participant Srv as lain server
Op->>CLI: lain repos add bytes <url>
CLI->>YAML: atomic write (temp + rename)
CLI->>Sock: write "reload\n"
Sock->>Bus: request_reload()
Bus->>Srv: rebuild task
Srv->>Srv: diff new vs live<br/>add/remove operations
Srv-->>Op: list_repos() now shows bytes
The flow covers the most common operator action: a CLI write that
reaches the running server without a restart. Hand-edits take the
same path through the notify watcher.
At minimum, list the repos you want indexed:
data_dir: /var/lib/lain
repos:
- id: bytes
source: { type: workspace_dir, path: /srv/bytes }
- id: tokio
source: { type: local_clone, url: https://example.com/billing.git }
- id: web
source: { type: shallow_clone, url: https://example.com/web.git }The two relevant bounding knobs (defaults shown):
| Field | Default | Purpose |
|---|---|---|
max_concurrent_indexers |
8 |
Semaphore that caps how many repos index in parallel at startup. |
ready_threshold |
0.8 |
Fraction of repos that must reach Ready health before the federation reports healthy. |
Each repo can be a workspace_dir (path on disk), local_clone (full
clone into data_dir/<id>), or shallow_clone (shallow clone that
refreshes on an interval). See docs/REPOS_YAML.md for the full
schema and id rules.
lain server --config /etc/lain/repos.yaml --transport http --port 9999Flags (all have defaults):
--config <path>— required; path torepos.yaml--transport <http|stdio>— defaultstdio(set tohttpfor the Command Center dashboard)--port <u16>— default9999, only meaningful for HTTP--log-level <env-filter>— defaultinfo; passed totracing'sEnvFilter--workspace <name>— optional; pin to a workspace declared inworkspaces.yaml. Useautoto read the operator's~/.config/lain/active_workspacepointer.--embedding-model <path>— optional; ONNX model forsemantic_search--no-process-attribution— disable/proc/<pid>/fdattribution on Linux/macOS
The loader reads the config, builds per-repo sources, and indexes each
repo up to max_concurrent_indexers at a time. After loading, the
server explicitly runs repo.index() on every registered repo so that
freshly loaded federations have actual nodes (the load-time projection
only sees whatever is already in the per-repo DB). A failed index()
demotes that repo to Degraded and the federation still comes up so
partial results remain queryable.
While the server runs, the Command Center dashboard is at GET / on
the HTTP port. The status bar shows pid, transport, repo/workspace
counts, and the reload state in real time. See
docs/command-center.md.
The federation is considered ready when ready_threshold (default 80%)
of the registered repos have health: "ready". Until then, individual
repos will report indexing, degraded, unavailable, or missing.
Watch the logs, the Command Center Overview tab, or poll
get_federation_health over MCP.
Federation mode registers six read-only MCP tools. All tool results are
returned as JSON text inside CallToolResult::content[0].text. Tool
failures set is_error: true and put the error message in the text
block.
The JSON shapes below are the exact serde-derived structs from
src/mcp/federation_tools.rs.
Returns every registered repo with its current health, path, last refresh/index timestamps, and node/edge counts.
- Arguments: none
- Returns: array of
RepoInfo:
[
{
"id": "bytes",
"path": "/srv/bytes",
"health": "ready",
"last_refreshed_unix": 1730000000,
"last_indexed_unix": 1730000000,
"node_count": 1234,
"edge_count": 987
}
]Field reference (struct: RepoInfo):
| Field | Type | Notes |
|---|---|---|
id |
string | The repo id from repos.yaml |
path |
string | Local on-disk path of the repo (the clone for local_clone / shallow_clone, the path for workspace_dir) |
health |
string | One of ready, indexing, degraded, unavailable, missing |
last_refreshed_unix |
i64 | Seconds since UNIX epoch when the source was last refreshed (e.g. shallow clone fetch) |
last_indexed_unix |
i64 | Seconds since UNIX epoch when RepoIndex::index() last completed |
node_count |
usize | Nodes in this repo's per-repo GraphDatabase |
edge_count |
usize | Edges in this repo's per-repo GraphDatabase |
Errors: none — always returns the current list, even when empty.
Returns the same RepoInfo payload as list_repos, but for a single
repo by id.
- Arguments:
id(string, required) — the repo id
- Returns: a single
RepoInfoobject (same shape as above) - Errors:
Missing required argument: id— noidkey in argsNotFound: repo <id>— no repo with that id is registered
Aggregate counts per health bucket, plus total node/edge counts and a rough memory estimate.
- Arguments: none
- Returns:
FederationHealth:
{
"total_repos": 12,
"ready": 10,
"indexing": 1,
"degraded": 0,
"unavailable": 1,
"missing": 0,
"total_nodes": 123456,
"total_edges": 78901,
"memory_estimate_bytes": 32456700
}The memory_estimate_bytes field is computed by the implementation as:
memory_estimate_bytes = total_nodes * 200 + total_edges * 100
It's a rough upper-bound heuristic, not a measured RSS.
Errors: none.
Case-insensitive substring search across every repo's symbols, matched
on name or path. Results are deduplicated by global_id, sorted by
(repo_id, name), and truncated to the caller-supplied limit.
- Arguments:
query(string, required) — substring to matchlimit(integer, required) — max number of results to return
- Returns: array of
SymbolMatch:
[
{
"global_id": "bytes:Function:src/auth.rs:verify_token",
"repo_id": "bytes",
"name": "verify_token",
"path": "src/auth.rs",
"kind": "function"
}
]Errors:
Missing required argument: queryMissing required argument: limitInvalid argument: limit must be a non-negative integer—limitis the wrong type or unparseable
The headline federation tool. Resolves a symbol across the federation,
then traverses incoming Calls edges in the federated graph — the
symbol's callers, answering "if I change this, what breaks?" — and
groups the visited nodes by repo. (The prose here previously said
"outgoing" while the example below listed caller_* nodes; the example
was the accurate half.)
- Arguments:
symbol(string, required) — the function/symbol namedepth(string, required) — a half-openRange<u32>like"1..3"(start inclusive, end exclusive)
- Returns:
CrossRepoBlastRadius:
{
"by_repo": {
"bytes": [
"bytes:Function:src/auth.rs:caller_a",
"bytes:Function:src/auth.rs:caller_b"
],
"tokio": [
"tokio:Function:src/checkout.rs:caller_c"
]
},
"total_count": 3,
"truncated": false
}Implementation notes:
- The traversal is outgoing-only along
Callsedges from the seed. - Results are bucketed by the
repo_idparsed out of each visited node's global id (repo_id:Kind:path:name). - A hard cap of 1000 nodes per call is enforced. When hit,
truncatedistrueandtotal_countequals the cap. More nodes exist beyond it; the caller should narrowdepthor pick a different seed. - The seed node itself is excluded by the depth lower bound
(
min_depth=1), so a symbol with no outgoingCallsedges yields an empty result, not an error.
Errors:
Missing required argument: symbolMissing required argument: depthInvalid depth: expected "<start>..<end>" or a number, got <input>—depthaccepts the range string"1..3"or a number3(shorthand for1..3); anything else is echoed with its typeNotFound: symbol <name> not found in any repo—resolve_symbolfound nothingAmbiguousSymbol: [...]—resolve_symbolfound the symbol in multiple repos; re-callget_cross_repo_blast_radiuswithrepo_idset to the repo that owns the seedNotFound: symbol <name> not found in repo <id>— the givenrepo_iddoes not own that symbol
Not advertised in tools/list — kept for callers that already use
it. The primary path is get_cross_repo_blast_radius with repo_id
set, which has identical semantics: the caller disambiguates the repo
explicitly, bypassing resolve_symbol.
- Arguments:
repo_id(string, required) — the repo id that owns the seed symbolsymbol(string, required) — the function/symbol namedepth(string, required) —Range<u32>like"1..3"
- Returns:
CrossRepoBlastRadius(same shape as above) - Errors:
Missing required argument: repo_id | symbol | depthInvalid depth: expected "<start>..<end>", got "<input>"NotFound: symbol <name> not found in repo <id>Invalid repo id: <id>— bad repo id (raised byRepoId::new's validation: empty, contains:, or contains/)
The federated graph on disk carries an explicit envelope: the LNF2
magic and a FEDERATION_GRAPH_VERSION (src/server/federation/graph_backend.rs).
A graph written by another version — or a corrupt body under a valid
header — is refused at load (FederationSchemaMismatch /
FederationPayloadCorrupt), never silently migrated.
Recovery is always the same command:
lain reindex --config ./repos.yaml # all workspaces
lain reindex --config ./repos.yaml --workspace backend-teamlain reindex rebuilds every repo's graph from source and writes one
federated_graph.bin. The result is deterministic in content: two runs on the
same tree produce the same nodes and edges. The file bytes are not identical
between runs, because node-index order and the last_lsp_sync / last_git_sync
timestamps vary. Compare two graphs by content, not by hash. The on-disk
format is unchanged, so an existing graph does not need a reindex to load.
On a 3-repo Saleor federation (about 91k nodes, 232k edges), cold reindex takes
about 30 s and peaks near 2.7 GB RSS (measured on current dev). The optional parse cache (parse_cache_mb
in repos.yaml) is off by default; see docs/REPOS_YAML.md.
It backs the old graph up to federated_graph.bin.bak, removes stale
sidecar state, and rebuilds every repo's per-repo graph plus the
federation backend. See CHANGELOG.md for which releases bump the
schema.
When repos.yaml declares services: / http_clients: / bindings:,
the federation derives a per-federation ContractIndex (PR 7) and
exposes the 13 contracts-package tools (PR 13). See
docs/REPOS_YAML.md for the config schema; the
design lives in
docs/superpowers/specs/2026-10-02-contract-coverage-and-protocols-design.md.
A service is a join unit: every contract node (HTTP route, OpenAPI
schema, HTTP client call, field read) is routed to its service by
the longest matching paths prefix under the service's repo.
Monorepos declare one service per paths entry — platform in the
fixture carries shipping and inventory as separate services that
share the same repo.
The joiner runs once per FederatedIndex and produces both the
desired Binds edge set and a fresh ContractIndex. It uses the
repos.yaml config_hash (blake3 of the canonical JSON of the
join-relevant sections) so two snapshots with different bindings:
entries have different ids and diff_contracts distinguishes them.
Every analysis result carries a Scope (§9.6) listing the
reviewed and unreviewed repos. A NoKnownImpact verdict
without an unreviewed list claiming the change is safe is the
default; reports name every repo that could not be reviewed.
An unresolved consumer in a reviewed repo is a concrete lead, so
it makes the change NeedsInvestigation instead of being listed
in the scope.
Snapshot views are built over PetgraphBackend::ephemeral:
every save() is a no-op, the in-memory GraphDatabase is fresh,
and project_graph is shared with the live path so both produce
identical node and edge sets from the same per-repo graph. What
snapshots do not contain: cross-repo Calls and
CrossRepoSameSymbol edges (§8.6), since the cache entries are
built without other repos. Contract impact does not need them
because services talk through Binds. Tools that depend on
symbol-level joins (get_cross_repo_blast_radius and friends) keep
working on live only.
The 13 tools (list_services, get_service, prepare_snapshot,
get_snapshot, list_contracts, get_contract, list_unresolved,
check_binding, diff_contracts, trace_impact, get_coverage,
resolve_evidence, read_source) follow §10.2 envelope,
§10.4 determinism (byte-identical structuredContent across stdio
and HTTP for the same view), §10.5 paging/limits, and the §13 error
codes. See
docs/quickstart-tools.md for a walkthrough.
Federation tools that take a repo_id (currently get_repo_info,
get_cross_repo_blast_radius / its _for_repo alias, and the
per-repo tools in single-workspace mode that are resolved against a
federation) use resolve_repo_for_tool in src/mcp/handler.rs. The rule, in order:
flowchart TB
A["args received"] --> B{"repo_id present<br/>and valid?"}
B -->|yes| R1["use repo_id"]
B -->|no| C{"symbol present?"}
C -->|yes| D["resolve_symbol(symbol)"]
D -->|0 repos| E["NotFound"]
D -->|1 repo| R2["that repo"]
D -->|>1 repos| F["AmbiguousSymbol"]
C -->|no| G{"0 repos registered?"}
G -->|yes| H["Config: no repos registered"]
G -->|no| I{"exactly 1 repo?"}
I -->|yes| R3["that repo"]
I -->|no| J["Config: multiple repos<br/>specify repo_id or symbol"]
- Explicit
repo_id. If the call'sargs.repo_idis present and non-empty, use it as-is. If it's malformed (failsRepoId::new's validation), returnInvalid repo id: <id>. - Symbol hint. Otherwise, if
args.symbolis present, callFederatedIndex::resolve_symbol(symbol):- exactly one repo owns that name → return that repo
- zero repos own it → return
NotFound: symbol <name> not found in any repo - multiple repos own it → return
AmbiguousSymbol(candidates)so the caller can present candidates to the user
- Zero repos. If neither arg is present and zero repos are
registered, return
Config: no repos registered. - Single-repo fallback. If neither arg is present and exactly one repo is registered, use that repo.
- Multi-repo dead end. If neither arg is present and multiple repos
are registered, return
Config: multiple repos; specify repo_id or symbol.
Examples (in args):
args |
Resolution |
|---|---|
{ "repo_id": "bytes" } |
Step 1 → bytes |
{ "symbol": "verify_token" }, unique owner |
Step 2 → owning repo |
{ "symbol": "verify_token" }, multiple owners |
Step 2 → AmbiguousSymbol |
{ "symbol": "nope" }, no owners |
Step 2 → NotFound |
{}, zero repos |
Step 3 → Config: no repos registered |
{}, one repo |
Step 4 → that repo |
{}, multiple repos |
Step 5 → Config: multiple repos; specify repo_id or symbol |
When the resolver returns AmbiguousSymbol, the MCP layer surfaces a
JSON payload of shape {"error":"ambiguous_symbol","candidates":[...], "message":"..."} inside the tool result text so the agent can present
the candidates and pick one for the next call.
flowchart LR
Q["MCP tool call"] --> RI["resolve_symbol<br/>O(1) lookup"]
RI --> T["GraphBackend::traverse<br/>BFS, depth-bounded"]
T --> CAP["cap at 1000 nodes"]
CAP --> OUT["CrossRepoBlastRadius"]
The federation has two measured workloads; both are in
tests/federation_benchmark.rs.
The small fixture is 10 repos × 5,000 nodes per repo = 50,000 total
nodes, with each repo holding a chain of Calls edges. The test runs
100 get_cross_repo_blast_radius calls (depth 1..5, walking a 5-hop
chain) and asserts the 99th-percentile latency is under 100 ms.
It runs on every PR:
cargo test --features test-utils --test federation_benchmark \
small_fixture_blast_radius_under_100ms_p99 -- --nocaptureThe large fixture is 200 repos × 50,000 nodes per repo = 10,000,000
total nodes. It also asserts memory_estimate_bytes < 32 GB. It is
marked #[ignore] (nightly-gated) and is not part of the per-PR suite:
cargo test --features test-utils --test federation_benchmark \
large_fixture -- --ignored --nocaptureget_federation_health.memory_estimate_bytes is computed by the
implementation as:
memory_estimate_bytes = total_nodes * 200 + total_edges * 100
This is a rough upper bound — actual RSS will depend on the
data_dir layout and OS page cache. Use it as a sanity check, not as
a precise measurement.
- The per-PR latency budget (100 ms p99) is measured against the small fixture only. Larger federations will be slower; the cost scales with the size of the reachable subgraph at the requested depth.
- The blast-radius traversal is bounded by a hard cap of 1000 nodes
per call. Deep traversals over a dense subgraph will hit this cap and
set
truncated: true. - Cross-repo symbol matching (
find_cross_repo_matches, called fromproject_repo) is O(repos × nodes) per repo projection and runs once at load time. Cold start of a large federation is dominated by indexing; subsequent restarts pay only the re-projection cost.
| Error | Cause | Remediation |
|---|---|---|
NotFound: symbol <name> not found... |
Repo still indexing or symbol absent | Verify list_repos health is ready; check short name |
AmbiguousSymbol |
Multiple repos own identical function name | Disambiguate by supplying repo_id from error candidates |
NotFound: repo <id> / Invalid repo id |
Id is unregistered or invalid (empty, contains :, /) |
Check list_repos for canonical spelling |
NotFound: symbol <name> in repo <id> |
Specified repo does not define the symbol | Use search_org to locate owning repo |
Config: no repos registered / multiple repos |
Empty config or missing required repo_id/symbol |
Check repos.yaml or provide disambiguating argument |
Config: yaml: ... |
Configuration parsing error | Check indentation, id, and source.type syntax |
Invalid argument: ... / Invalid depth: ... |
Malformed limit or depth (<start>..<end>) |
Format depth as "1..3" (exclusive end); limit as positive integer |
truncated: true |
Traversal hit 1000-node cap | Decrease depth or use search_org first |
indexing: initial state; transitions toreadyupon completion.degraded:index()failed; check server logs for language server binary or permission issues.unavailable: sourcefetch()failed (e.g., unreachable remote).missing: expected repository data directory is missing.
Git operations run via the lain-git-sidecar child daemon by default (2 s IPC timeout, automatic bounded recovery).
- Check
get_healthorget_capabilities: sidecar telemetry reportsalive,child_pid, and failure metrics. - Set
LAIN_GIT_SENSOR=in_processonly when diagnosing sidecar-specific issues. - For deep diagnosis of wedged processes, use
scripts/debug-hung-server.sh <repos.yaml>.
Workspaces are named groups of repos that the federation engine indexes
together as a coherent unit. A workspace = a subset of repos.yaml's
repos, picked at server start via --workspace <name> (or auto to
read the operator's ~/.config/lain/active_workspace pointer). The
workspace config lives in workspaces.yaml next to repos.yaml.
flowchart LR
subgraph Y["repos.yaml"]
R1[bytes]
R2[tokio]
R3[db-client]
R4[web]
end
subgraph W["workspaces.yaml"]
WS1["backend-team<br/>members: [bytes, tokio, db-client]"]
WS2["payments-ws<br/>members: [tokio, db-client]"]
end
R1 --> WS1
R2 --> WS1
R2 --> WS2
R3 --> WS1
R3 --> WS2
WS1 --> ACT["active workspace<br/>(~/.config/lain/active_workspace)"]
ACT --> SRV["--workspace auto → federation loads only WS1's repos"]
| Mode | Use it when |
|---|---|
Federation (lain server --config repos.yaml) |
Org-wide questions across all repos |
Workspace (lain server --config repos.yaml --workspace <name>) |
Questions scoped to a named subset ("backend-team", "payments-ws") |
- Declare workspaces in
workspaces.yaml(same directory asrepos.yaml):workspaces: - name: backend-team members: [bytes, tokio, db-client]
- Pick one:
lain workspaces use backend-team(writes~/.config/lain/active_workspace). - Start the server:
lain server --config repos.yaml --workspace auto --transport http --port 9999. The federation loads onlybackend-team's members. All 6 federation tools operate scoped. - Workspace switching is restart-only —
workspaces.yamledits are hot-reloaded (seedocs/hot-reload.md) but the active workspace is read once at startup.
lain workspaces create / list / show / use / current / forget
See lain workspaces --help.
In addition to the 6 federation tools (scoped to the workspace's repos):
list_workspaces— all known workspaces + which is activeget_active_workspace— the active workspace's name + membersget_workspace(name)— full detail on one workspace
When lain server --transport http is running, the Command Center at
GET / shows:
- Active workspace panel (name + members + their paths/healths)
- Config panel (paths + repo counts)
- Per-workspace D3 force-directed graph view (Functions/Methods/Classes,
Topic hubs and their Produces/Consumes legs, + Calls/Imports, color by
repo_id, dashed lines for cross-repo Calls)
get_agent_strategy (a built-in MCP tool) includes a "Workspace mode"
section that documents the workspace tools + the repo_id resolution
rule when scoped.
Federation mode is additive on top of single-workspace mode.
Run the same directory under a one-member repos.yaml and start the
server with lain server --config ./repos.yaml --transport stdio.
Federation-only tools (list_repos, get_federation_health, etc.)
still work; per-repo tools (query, coupling, blast_radius, etc.)
work via the workspace's single repo.
Switching modes is opt-in. To turn a single-repo deployment into a
federation, write a repos.yaml (see docs/REPOS_YAML.md)
and start the server with lain server --config repos.yaml. The
existing single-workspace CLI does not need to change.
The HTTP /health endpoint is shared between modes. Federation mode
attaches a federation field describing per-repo health, total
counts, and the memory estimate; single-workspace mode reports
federation: null. The rest of the /health payload is unchanged.
The block below starts the federation server in the background, waits for it to come up, and exercises two federation tools via the MCP HTTP JSON-RPC endpoint.
# Start the federation server:
lain server --config repos.yaml --transport http --port 9999 &
# Wait for it to be ready:
sleep 30
# List repos:
curl -s -X POST http://localhost:9999/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_repos","arguments":{}},"id":1}' \
| jq '.result.content[0].text | fromjson'
# Search org-wide:
curl -s -X POST http://localhost:9999/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_org","arguments":{"query":"verify","limit":5}},"id":1}' \
| jq '.result.content[0].text | fromjson'The sleep 30 is a rough budget for the federation to reach
ready_threshold of registered repos. Replace it with a poll on
get_federation_health when automating. Federation tool results are
JSON text wrapped inside CallToolResult::content[0].text; the jq
filter unwraps the inner JSON so the output is directly readable.
The full bash block above is shell-syntax-validated with:
awk '/^```bash$/,/^```$/' docs/FEDERATION.md | bash -n