Configuration Loading and Validation
- internal/config/config.go
- Default
- Load
- substituteEnv
- ErrMissingEnv
- ErrInvalid
- Providers
- Provider
- Embeddings
- Workspace
- Blob
- Telemetry
- Incident
- Skills
Relevant source files · 5
Korvus is configured by a single YAML file, and internal/config is the only package allowed to read the environment. The package doc is explicit about the rule: every configurable value originates here, so no other package calls os.Getenv package doc. Values reach the file as ${VAR} references and are substituted at load time, which keeps secrets out of the file while leaving the file itself a complete description of the deployment package doc.
The surface is one struct of nested sections — Config holds Server, Database, Providers, Embeddings, Workspace, Blob, MCP, Telemetry, Session, Incident and Skills Config. Each section is a plain struct with YAML tags, and each is handed downstream as the slice of it that the consumer needs: the wiring code passes cfg.Database to the pool builder, cfg.Blob to the blob-store builder, cfg.Workspace to the sync starter, cfg.Incident to the alert ingestor and cfg.Skills.Path to the skill catalog run. Downstream code therefore never needs the whole Config, and the only consumer that does take the whole thing is the agent builder, which needs both provider definitions at once buildAgent.
The package also draws a line around what it deliberately does not expose. The agent's iteration ceiling and session timeout are constants in internal/agent, not fields here: they are safety rails rather than tuning knobs, so an operator who needs different values makes a code change, which correctly signals that the change is not routine package doc. Adding them would be a design reversal rather than a feature.
How it works
Loading is a three-step pipeline: read the file, substitute the environment, then decode and validate.
Load is the entry point. It reads the file with os.ReadFile and delegates the rest to an unexported parse, which exists separately so tests can drive the pipeline on a byte slice without touching the filesystem Load. The parse function substitutes first, then decodes, then validates, wrapping every failure with the source path so the operator knows which file is at fault parse.
Environment substitution
substituteEnv replaces every ${VAR} with its value substituteEnv. Two deliberate design decisions live here.
First, the braces are required. The pattern is \$\{([A-Za-z_][A-Za-z0-9_]*)\}, so a bare $VAR is left alone and a password or PromQL fragment containing a dollar sign is not mangled envPattern.
Second, an unset variable is an error rather than an empty string. Substituting "" would produce a config that parses and then fails somewhere far away — an empty DSN would surface as a connection error naming no cause, and an empty API key would surface as a 401 from the provider — so failing at load time names the variable instead substituteEnv. Every miss is collected before returning, so a deployment missing five variables learns all five at once instead of one per restart substituteEnv. The error is wrapped around ErrMissingEnv, which reports "environment variable not set" ErrMissingEnv.
Substitution also skips comments, and that is a load-bearing detail rather than a convenience. The example file documents optional settings as commented-out lines carrying ${VAR} references; an operator who has not set those variables is not misconfigured, and without comment-skipping, commenting a line out would still make its variable mandatory — the opposite of what commenting something out means commentPattern. The implementation masks comments in a scratch copy that preserves byte offsets, so the matched spans map directly back onto the original bytes substituteEnv.
Decoding
Decoding starts from Default() rather than from a zero Config parse. The distinction matters most for timeouts, where the zero value means no timeout at all: an omitted key means "use the default" instead of "use the zero value" Default.
The YAML decoder is configured with KnownFields(true), so a typo becomes an error naming the key. Silently ignoring an unknown field is how an operator ends up believing a setting is in effect when it was never read parse.
Validation
Validate checks everything that can be checked without I/O, and reports all problems together for the same reason substituteEnv collects misses: an operator fixing a config file should not have to discover its faults one restart at a time Validate. The findings are accumulated in a problems slice and returned as a single ErrInvalid-wrapped error with one bullet per problem Validate.
The checks fall into a few groups. The server must have a valid port and a webhook path that begins with / Validate. The database must have a DSN, a pool ceiling of at least 2 and a floor that does not exceed the ceiling Validate. The max_conns floor exists because store.Migrate pins one connection for the advisory lock and needs a second for goose; with one it deadlocks until the context expires, so the failure is caught here where it can name the config key Validate.
Providers are resolved by name. providers.default must be set and must match a definition; providers.fallback is optional but must also match a definition when set, since the agent's sticky failover simply has nowhere to fail over to otherwise Validate Providers. Each definition needs a name, names must be unique, the type must be openai_compatible or anthropic, and model is required Validate. base_url is required for openai_compatible and optional for anthropic Validate — the difference is that the Anthropic SDK carries a default endpoint while an OpenAI-compatible endpoint has no canonical address to fall back on.
Embeddings are optional as a whole: a deployment without document retrieval configures nothing in that block. But a partially configured one is always a mistake, so any field implies all the required ones Validate. The dimensions value is compared against EmbeddingDimensions, the width migration 001 fixes on the docs_chunks.embedding column, because a mismatch would otherwise surface as an opaque insert failure at the first workspace sync Embeddings EmbeddingDimensions.
The blob section allows exactly one backend: blob.endpoint requires a bucket, and setting blob.path together with blob.endpoint is rejected because a session manager holds one blob store, so naming two leaves no right answer about where the bytes went Validate. MCP servers need a name and a URL, and names must be unique, because tool names are namespaced by server and the registry rejects a collision at startup Validate.
Telemetry's sample ratio must lie between 0 and 1 Validate. The incident section requires a positive dedup window, a non-negative debounce delay, and rejects blank entries in ignored_labels — such an entry excludes nothing and can only be a mistake Validate. Finally, skills.path must be set Validate.
The configuration surface
The example file korvus.example.yaml documents every key, and the loader rejects unknown ones, so a typo fails at startup naming the key rather than being silently ignored korvus.example.yaml. The table below lists the keys the validator constrains, with the section defaults filled in by Default().
| Key | Default | Constraint enforced at load |
|---|---|---|
server.port |
8080 |
must be between 1 and 65535 |
server.webhook_path |
/webhook/alertmanager |
must begin with / |
database.dsn |
none | must be non-empty |
database.max_conns |
10 |
must be at least 2 |
database.min_conns |
2 |
must not exceed max_conns |
providers.default |
none | required; must name a definition |
providers.fallback |
none | optional; must name a definition if set |
providers.definitions[].name |
none | required; unique |
providers.definitions[].type |
none | required; openai_compatible or anthropic |
providers.definitions[].base_url |
none | required for openai_compatible |
providers.definitions[].model |
none | required |
embeddings.* |
dimensions: 1536 |
all-or-nothing; dimensions must equal EmbeddingDimensions |
workspace.ref |
main |
required when workspace.url is set |
workspace.sync_interval |
5m |
must not be negative |
blob.bucket |
none | required when blob.endpoint is set |
blob.path + blob.endpoint |
none | mutually exclusive |
mcp.servers[].name |
none | required; unique |
mcp.servers[].url |
none | required |
telemetry.sample_ratio |
1.0 |
must be between 0 and 1 |
incident.dedup_window |
30m |
must be positive |
incident.debounce_delay |
30s |
must not be negative |
incident.ignored_labels[] |
pod, instance, uid |
must not be blank |
skills.path |
./skills |
required |
The two provider sections are worth contrasting. config.Providers and config.Embeddings are separate because Anthropic has no embeddings API, so a deployment can run Anthropic for completion and something OpenAI-compatible for retrieval Embeddings. At runtime the definitions become the provider-neutral model.Provider interface that the agent loop is written against — a single completion call that must not retry internally and must wrap failures in a *ProviderError so the loop can classify them Provider — with one adapter per definition type newProvider.
The Skills section is easy to misread. skills.path is a dev-only override: the catalog for sessions pinned to no workspace commit, which only a deployment without workspace.url runs. A pinned session's system prompt is built from the skills/ tree at its own commit, and this path plays no part in it. The path is read-only at runtime Skills. The catalog itself is loaded once at startup via skill.Load and never reloaded, because skills are read-only at runtime and a session's prompt is static Load.
Error handling
The package exposes two sentinel errors, and both are designed to be matched with errors.Is.
ErrMissingEnv wraps the substitution failure and names every variable that had no value behind it ErrMissingEnv. ErrInvalid wraps a configuration that parsed but cannot be used, with the individual findings as bullets under it ErrInvalid Validate.
Both errors surface at startup. The process loads the configuration before it opens a database socket or runs a migration, and the first thing run does with the loaded config is set up logging and telemetry run. The migration that would fail on an undersized pool therefore never gets the chance: the configuration is rejected first.
Relationships
graph TD file["internal/config/config.go"] --> Load Load --> substituteEnv substituteEnv --> ErrMissingEnv Load --> Default Load --> ErrInvalid Default --> Providers Default --> Embeddings Default --> Workspace Default --> Blob Default --> Telemetry Default --> Incident Default --> Skills Providers --> Provider
The flow at startup is: Load reads the file, substituteEnv resolves ${VAR} references (failing with ErrMissingEnv if any is unset), Default supplies the baseline, the YAML decoder overlays the file's values, and Validate rejects the result with ErrInvalid if anything is wrong. The validated sections are then handed to the subsystems that own them — providers to the agent, the workspace to the sync layer, blob settings to the blob store, and the incident settings to the alert ingestor.
Reference
| Symbol | Kind | What it does | Location |
|---|---|---|---|
Config |
struct | The whole configuration surface: one nested section per concern | src/internal/config/config.go#L32-L44 |
Default |
function | Returns the configuration with every optional value populated | src/internal/config/config.go#L224-L258 |
Load |
function | Reads, substitutes and validates a configuration file | src/internal/config/config.go#L260-L267 |
parse |
function | Load's body, split out so tests can exercise it on a byte slice |
src/internal/config/config.go#L269-L292 |
substituteEnv |
function | Replaces every ${VAR} with its value; collects every miss |
src/internal/config/config.go#L324-L367 |
envPattern |
variable | Matches ${VAR}; braces required, bare $VAR untouched |
src/internal/config/config.go#L299-L301 |
commentPattern |
variable | Matches YAML comments so substitution skips them | src/internal/config/config.go#L303-L310 |
ErrMissingEnv |
variable | Reports a ${VAR} reference with nothing behind it |
src/internal/config/config.go#L312-L312 |
ErrInvalid |
variable | Reports a configuration that parsed but cannot be used | src/internal/config/config.go#L370-L370 |
Validate |
method | Checks everything checkable without I/O; reports all problems together | src/internal/config/config.go#L377-L527 |
EmbeddingDimensions |
constant | Width migration 001 fixes on docs_chunks.embedding |
src/internal/config/config.go#L133-L134 |
ProviderTypeOpenAICompatible |
constant | The openai_compatible provider type |
src/internal/config/config.go#L74-L74 |
ProviderTypeAnthropic |
constant | The anthropic provider type |
src/internal/config/config.go#L75-L75 |
Providers |
struct | Names the default and fallback providers and holds their definitions | src/internal/config/config.go#L84-L88 |
Providers.Find |
method | Returns the definition with the given name | src/internal/config/config.go#L100-L107 |
Provider |
struct | One model endpoint: name, type, base URL, model, API key | src/internal/config/config.go#L91-L97 |
Embeddings |
struct | The embedding endpoint, kept separate from Providers |
src/internal/config/config.go#L112-L121 |
Workspace |
struct | The git repository holding skills and documentation | src/internal/config/config.go#L138-L145 |
Blob |
struct | Storage for report and session-artifact bytes | src/internal/config/config.go#L148-L164 |
Telemetry |
struct | OTLP export settings; an empty endpoint disables tracing | src/internal/config/config.go#L181-L186 |
Incident |
struct | The alert path: dedup window, ignored labels, debounce, close-on-resolved | src/internal/config/config.go#L200-L208 |
Skills |
struct | skills.path, the dev-only override for unpinned sessions |
src/internal/config/config.go#L215-L217 |
Generated by astrolabe from commit 0c2dca73 on branch main.