Astrolabe / ajxpublic /korvus
Repository

Configuration Loading and Validation

reference scope internal/config Verification failed
  • 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.