config — schema, overrides, secrets
config è il componente trasversale che legge, valida, espone la
configurazione a tutto il resto del sistema. Definisce una unica fonte
di verità tipata (Settings) costruita per layering da
file TOML, variabili d'ambiente e secret store. Questo doc non è il luogo
dei valori: è il luogo del meccanismo.
Tre categorie disgiunte. La distinzione è di design, non di comodo: spostare qualcosa da "non configurabile" a "configurabile" richiede un cambiamento nella Costituzione o un bump di versione del contratto.
| Categoria | Cosa contiene | Dove vive |
|---|---|---|
| Non configurabile | Le quattro Leggi. Forbidden paths hard-coded. Algoritmi di firma. Struttura del prompt di sistema. Marker di boundary (<constitution>). |
SOUL.md + costanti nel codice sorgente. |
| Configurabile con versione | Livelli di autonomy disponibili. Registro delle capability. Modelli LLM usati per tier. Policy di retention della memoria. | workspace/IDENTITY.md + config.toml dichiarato. |
| Configurabile liberamente | Tutto il resto: path, porte, timeout, soglie (TAU, EPSILON), credenziali, URL esterni. | config.toml, env vars, secret store. |
La configurazione finale è costruita per merge a strati: ogni strato successivo può aggiungere o sovrascrivere valori degli strati precedenti, mai toglierli. La validazione tipata avviene dopo il merge.
Lo schema è una gerarchia di BaseSettings annidati, con
env_prefix e supporto TOML via pydantic-settings 2.x.
from pydantic import BaseModel, SecretStr, Field, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from pathlib import Path
class GatewaySettings(BaseModel):
bind_host: str = "127.0.0.1"
bind_port: int = Field(8810, ge=1024, le=65535)
public_base_url: HttpUrl | None = None
class LLMProviderSettings(BaseModel):
name: str # "anthropic" | "openai" | "local-vllm"
api_key: SecretStr | None = None
base_url: HttpUrl | None = None
model: str # es. "claude-sonnet-4-6"
max_tokens: int = 4096
class CostTierSettings(BaseModel):
local_fast: LLMProviderSettings
frontier: LLMProviderSettings
budget_soft_eur: float = 2.0
budget_hard_eur: float = 5.0
class MemorySettings(BaseModel):
workspace_path: Path = Path("~/.myclaw/workspace").expanduser()
episodic_retention_days: int = 60
semantic_budget_kb: int = 2
reflection_cron: str = "0 3 * * *"
tau_days: int = 30 # Ebbinghaus decay
class SynapseSettings(BaseModel):
retrieval_epsilon: float = Field(0.05, ge=0, le=0.5)
strike_threshold: int = 3
dormant_days: int = 90
quarantine_grace_days: int = 30
class ObservabilitySettings(BaseModel):
audit_path: Path = Path(".audit")
log_level: str = "INFO"
metrics_enabled: bool = True
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYCLAW_",
env_nested_delimiter="__",
toml_file=("/etc/myclaw/config.toml",
"~/.config/myclaw/local.toml"),
secrets_dir="/run/secrets/myclaw", # file-per-secret, 0600
extra="forbid",
frozen=True,
)
gateway: GatewaySettings = GatewaySettings()
llm: CostTierSettings
memory: MemorySettings = MemorySettings()
synapse: SynapseSettings = SynapseSettings()
obs: ObservabilitySettings = ObservabilitySettings()
extra="forbid": chiavi sconosciute in un TOML → errore di avvio (tipografia catturata subito).frozen=True: Settings è immutabile dopo il load; nessun ramo di codice può "correggere runtime" la config.SecretStr sono esclusi da model_dump_json() default (richiedono opt-in esplicito).
Un secret non è una variabile di configurazione. I segreti abitano un proprio
canale e vengono letti come SecretStr, il cui valore è accessibile
solo con .get_secret_value(). Ogni logger normale li stampa come
**********.
/run/secrets/myclaw/, uno per chiave (es. llm__frontier__api_key), mode 0600, owner myclaw. Letti via secrets_dir di pydantic-settings. Preferita.MYCLAW_LLM__FRONTIER__API_KEY=.... Accettate ma l'environment è visibile in /proc/<pid>/environ: meno robusto.SecretStr che venga popolato da config.toml/local.toml (ValueError all'avvio).obs anche a DEBUG.systemctl kill -s HUP myclaw → hot reload (vedi §6).myclaw diag li redige automaticamente)./run/secrets/ (tmpfs) è più che adeguato al
contesto domestico. Rimandato senza rimpianti.
Non tutta la config può essere ricaricata a caldo senza rischio. La separo in tre classi:
| Classe | Esempio | Politica |
|---|---|---|
| Hot-reloadable | log_level, soglie TAU/EPSILON, reflection_cron | SIGHUP → ri-validate → swap atomico del Settings. |
| Restart-required | bind_host/port, workspace_path, provider LLM | Modifica rilevata → warning in log; gli effetti si applicano al prossimo restart. |
| Non modificabile a runtime | Forbidden paths, struttura del prompt | Non vivono qui: appartengono a constitution. |
Il loader mantiene una classificazione per campo: un campo marcato
restart_required=True che cambia tra due load non applica il nuovo
valore ma logga la differenza come evento config.restart_pending.
Una manciata di comandi per trasparenza e ops. Tutti rispettano la regola secrets-redatti.
| Comando | Fa |
|---|---|
myclaw config show | Stampa la config effettiva con secrets redatti + provenance per ogni campo (da quale strato viene). |
myclaw config verify | Valida senza avviare il gateway. Exit code 0/1. |
myclaw config diff | Mostra le differenze fra config attiva e quella che risulterebbe da un nuovo load. |
myclaw config reload | Equivalente a SIGHUP: ri-load + applicazione dei soli campi hot-reloadable. |
myclaw config schema | Emette JSON Schema corrispondente a Settings per editor / review. |
from typing import Protocol, Literal
from pathlib import Path
class ConfigLoader(Protocol):
def load(self) -> "Settings":
"""Merge dei 5 strati + validazione. Solleva ValidationError con
path del campo offensore."""
...
def reload(self, signal: Literal["SIGHUP"] | None = None) -> "ConfigDiff":
"""Esegue load, confronta con la corrente, applica solo i campi
hot-reloadable. Ritorna il diff."""
...
def provenance(self, field_path: str) -> Literal["default", "toml", "local", "env", "secret"]:
"""Dice da quale strato viene l'attuale valore di un campo."""
...
@dataclass
class ConfigDiff:
applied: list[str] # campi hot-reloadable cambiati e applicati
restart_pending: list[str] # campi cambiati ma non applicati
unchanged: int
new_hash: str # sha256 del dump redacted
# Errori
class ConfigValidationError(Exception): ...
class SecretInTomlError(Exception): ... # secret letto da TOML: rifiutato
class UnknownConfigKeyError(Exception): ... # extra="forbid"
| Alternativa | Perché scartata |
|---|---|
| YAML invece di TOML | Più flessibile ma più subdolo (indentazione, tipi impliciti). TOML è meno espressivo ma rende gli errori visibili nei diff. |
Config fatta a mano con configparser | Nessun typing, nessuna validazione. Rifatta da zero ogni volta. |
Dataclass + json.load | Validazione a mano, nessun supporto env/secrets. pydantic-settings è la scelta ergonomica. |
| Config mutabile a runtime via API | Apre a modifiche non auditate. frozen=True impedisce deriva. |
| Tutto in env (12-factor stretto) | Buono per container stateless, doloroso per home agent con decine di parametri. File + env è pragmatic. |
| Integrazione Vault/Bitwarden in v1 | Richiede infrastruttura, chiavi, rotazione automatica. Rimandato. |
| Reload "esplosivo" (swap di tutto a caldo) | Rischioso: cambiare bind_port a caldo significa chiudere connessioni. La tabella di classe in §6 è la difesa. |
| Invariante | Test |
|---|---|
| Merge a strati | default + config.toml con bind_port=9000 + env MYCLAW_GATEWAY__BIND_PORT=9100 → valore effettivo 9100, provenance="env". |
| Kiave sconosciuta blocca avvio | config.toml con [gateway] mistery=1 → UnknownConfigKeyError, gateway non parte. |
| Secret da TOML rifiutato | llm.frontier.api_key = "..." in local.toml → SecretInTomlError. |
| Secret da file 0600 | File /run/secrets/myclaw/llm__frontier__api_key → popolato come SecretStr, get_secret_value() torna il contenuto. |
| SecretStr non in log | Setup logger & dump config → stringa dei log non contiene il valore del secret (grep). |
| Frozen | settings.gateway.bind_port = 9999 → FrozenInstanceError. |
| Hot reload campi compatibili | SIGHUP dopo change di obs.log_level → applied=["obs.log_level"], restart_pending=[]. |
| Hot reload campi restart-required | SIGHUP dopo change di gateway.bind_port → applied=[], restart_pending=["gateway.bind_port"], evento config.restart_pending. |
| Provenance | Per ogni campo, provenance(path) restituisce esattamente lo strato originante. |
| Config show redige secrets | Output di myclaw config show non contiene valori di SecretStr, solo **********. |
| Schema exportabile | myclaw config schema produce un JSON Schema valido (verifica con jsonschema stesso). |
| Diag redige secrets | Snapshot diagnostico non contiene valori di SecretStr. |
| Riferimento | Cosa abbiamo preso |
|---|---|
| pydantic-settings 2.x | Loader a strati (env, toml, secrets_dir), SecretStr, validazione tipata. |
| 12-factor App | Env per config, secrets in canale separato — adattato (non stretto) al contesto home. |
| TOML spec | Formato con diff leggibili, tipi espliciti, sezioni. |
systemd LoadCredential= | Meccanismo che popola /run/secrets/ in modo compatibile con pydantic-settings secrets_dir. |
| Constitution §4 | Giustificazione per cui Costituzione non passa da config. |
| Policy | Molte soglie tunabili di retrieval/quota abitano in config e vengono lette da policy. |
| Observability §audit | Evento config.loaded / config.restart_pending come punto di verità. |
config.loaded e config.restart_pending.
myclaw — config microprogettazione v1.0 — 2026-04-22
Ultimo doc dell'ordine canonico. La tabella di microprogettazione è completa.