"""Validate a hive overlay before any of it is applied.
The rule is all-or-nothing for the parts that describe behaviour, and
ignore-with-a-warning for the parts that describe the document:
- an unknown top-level section, an unknown setting, an unknown profile or an
unknown MCP server field is *forward compatibility* — a newer hive may
know more than this instance. It is dropped and reported, never fatal;
- a known field with the wrong type, an invalid route, or an MCP config that
does not parse is a broken document. Applying the other half of it would
leave the fleet in a state nobody wrote, so the whole document is
rejected and the previous one (from cache) stays in force.
Missing fields are the normal case, not an error: they fall back to the local
config by definition of the overlay.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from navi.overlay.merge import merge_patch
from navi.overlay.schema import DENIED_SETTINGS, KNOWN_SECTIONS
_LLM_KEYS = {"targets", "routes", "probe"}
_TOOL_SCOPE_KEYS = {"agent", "subagent"}
_TOOL_SCOPE_INNER = {"native", "mcp"}
@dataclass
class ValidationResult:
merged: dict = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
unknown_fields: list[str] = field(default_factory=list)
forbidden_fields: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def validate_envelope(layers: object) -> tuple[list[dict], list[str]]:
"""Check the transport shape; return the usable layers and warnings."""
warnings: list[str] = []
if not isinstance(layers, list):
raise ValueError("'layers' must be a list")
usable: list[dict] = []
for index, layer in enumerate(layers):
if not isinstance(layer, dict):
raise ValueError(f"layer #{index} is not an object")
name = layer.get("name", f"layer{index}")
config = layer.get("config")
if not isinstance(name, str) or not name:
raise ValueError(f"layer #{index} needs a string 'name'")
if not isinstance(config, dict):
raise ValueError(f"layer '{name}' needs an object 'config'")
usable.append({"name": name, "config": config})
return usable, warnings
def validate_layers(base_settings, layers: list[dict]) -> ValidationResult:
"""Merge the layers and validate the result against the local base."""
result = ValidationResult()
merged: dict = {}
for layer in layers:
merged = merge_patch(merged, layer["config"]) # type: ignore[assignment]
unknown_sections = sorted(set(merged) - KNOWN_SECTIONS)
for name in unknown_sections:
result.warnings.append(f"unknown section '{name}' ignored")
merged.pop(name, None)
if "settings" in merged:
merged["settings"] = _clean_settings(base_settings, merged["settings"], result)
if "profiles" in merged:
merged["profiles"] = _clean_profiles(merged["profiles"], result)
if "llm" in merged:
merged["llm"] = _clean_llm(base_settings, merged["llm"], result)
if "mcp_servers" in merged:
merged["mcp_servers"] = _clean_mcp(merged["mcp_servers"], result)
result.merged = {k: v for k, v in merged.items() if v}
return result
# ── settings ────────────────────────────────────────────────────────────────
def _clean_settings(base_settings, patch: object, result: ValidationResult) -> dict:
if not isinstance(patch, dict):
result.errors.append("settings must be an object")
return {}
known = set(type(base_settings).model_fields)
cleaned: dict = {}
for name, value in patch.items():
if name not in known:
result.unknown_fields.append(f"settings.{name}")
continue
if name in DENIED_SETTINGS:
result.forbidden_fields.append(f"settings.{name}")
continue
cleaned[name] = value
try:
type(base_settings)(**{**base_settings.model_dump(), **cleaned})
except Exception as exc: # validation error text names the bad field
result.errors.append(f"settings: {exc}")
return {}
return cleaned
# ── profiles ────────────────────────────────────────────────────────────────
def _clean_profiles(patch: object, result: ValidationResult) -> dict:
if not isinstance(patch, dict):
result.errors.append("profiles must be an object")
return {}
from navi.profiles.base import AgentProfile
from navi.profiles import ALL_PROFILES
base_by_id = {p.id: p for p in ALL_PROFILES}
known = set(AgentProfile.model_fields)
cleaned: dict = {}
for profile_id, profile_patch in patch.items():
if profile_id not in base_by_id:
result.warnings.append(f"unknown profile '{profile_id}' ignored")
continue
if not isinstance(profile_patch, dict):
result.errors.append(f"profiles.{profile_id} must be an object")
continue
filtered: dict = {}
for name, value in profile_patch.items():
if name not in known:
result.unknown_fields.append(f"profiles.{profile_id}.{name}")
continue
if name == "tools":
value = _clean_tools(profile_id, value, result)
if value is None:
continue
filtered[name] = value
if "id" in filtered and filtered["id"] != profile_id:
result.errors.append(
f"profiles.{profile_id}: 'id' cannot be changed ({filtered['id']!r})"
)
continue
try:
AgentProfile.model_validate(
merge_patch(base_by_id[profile_id].model_dump(), filtered)
)
except Exception as exc:
result.errors.append(f"profiles.{profile_id}: {exc}")
continue
if filtered: # a profile patch that only held unknown fields says nothing
cleaned[profile_id] = filtered
return cleaned
def _clean_tools(profile_id: str, patch: object, result: ValidationResult) -> dict | None:
if not isinstance(patch, dict):
result.errors.append(f"profiles.{profile_id}.tools must be an object")
return None
out: dict = {}
for scope, scope_patch in patch.items():
if scope not in _TOOL_SCOPE_KEYS:
result.unknown_fields.append(f"profiles.{profile_id}.tools.{scope}")
continue
if not isinstance(scope_patch, dict):
result.errors.append(f"profiles.{profile_id}.tools.{scope} must be an object")
continue
inner: dict = {}
for key, value in scope_patch.items():
if key not in _TOOL_SCOPE_INNER:
result.unknown_fields.append(
f"profiles.{profile_id}.tools.{scope}.{key}"
)
continue
inner[key] = value
out[scope] = inner
return out
# ── llm ─────────────────────────────────────────────────────────────────────
def _clean_llm(base_settings, patch: object, result: ValidationResult) -> dict:
if not isinstance(patch, dict):
result.errors.append("llm must be an object")
return {}
filtered: dict = {}
for key, value in patch.items():
if key not in _LLM_KEYS:
result.unknown_fields.append(f"llm.{key}")
continue
filtered[key] = value
from navi.llm.config import LLMConfigError, load_llm_raw, parse_llm_config
try:
local_raw = load_llm_raw(base_settings.llm_config_file) or {}
except Exception as exc: # a broken local file must not block a fix from above
result.warnings.append(f"local llm.json is invalid ({exc}); merged without it")
local_raw = {}
merged = merge_patch(local_raw, filtered)
if not merged:
return {}
try:
# Secrets are resolved when the backend is actually built; doing it
# here would import the settings singleton while it is being created.
parse_llm_config(merged, source="hive-overlay", resolve_secrets=False)
except LLMConfigError as exc:
result.errors.append(f"llm: {exc}")
return {}
return filtered
# ── MCP servers ─────────────────────────────────────────────────────────────
def _clean_mcp(patch: object, result: ValidationResult) -> dict:
"""Validate per-server patches against the local server definitions.
A patch may define a server the machine does not have (the hive is the
source of truth for the fleet) or override fields of one it has. It
cannot *remove* one: ``null`` already means "drop this key" at the
merge-patch level, so there is no value left here to act on. Removing a
server stays a local-file operation.
"""
if not isinstance(patch, dict):
result.errors.append("mcp_servers must be an object")
return {}
from navi.mcp.config import McpServerConfig, load_mcp_servers
local = {name: cfg.model_dump() for name, cfg in load_mcp_servers().items()}
known = set(McpServerConfig.model_fields)
cleaned: dict = {}
for server, server_patch in patch.items():
if not isinstance(server_patch, dict):
result.errors.append(f"mcp_servers.{server} must be an object")
continue
filtered: dict = {}
for key, value in server_patch.items():
if key not in known:
result.unknown_fields.append(f"mcp_servers.{server}.{key}")
continue
filtered[key] = value
try:
McpServerConfig.model_validate(merge_patch(local.get(server, {}), filtered))
except Exception as exc:
result.errors.append(f"mcp_servers.{server}: {exc}")
continue
cleaned[server] = filtered
return cleaned