kogral/docs/architecture/config-driven.md

540 lines
13 KiB
Markdown
Raw Permalink Normal View History

2026-01-23 16:11:07 +00:00
# Config-Driven Architecture
The KOGRAL follows a **config-driven architecture** where all behavior is defined through Nickel configuration files rather than hardcoded in Rust.
## Philosophy
**"Configuration, not code, defines behavior"**
Instead of hardcoding storage backends, embedding providers, or query parameters, KOGRAL uses a layered configuration system that composes settings from multiple sources:
1. **Schema contracts** (type definitions)
2. **Defaults** (base values)
3. **Mode overlays** (dev/prod/test optimizations)
4. **User customizations** (project-specific overrides)
This approach provides:
-**Type safety** - Nickel contracts validate configuration before runtime
-**Composability** - Mix and match configurations for different environments
-**Discoverability** - Self-documenting schemas with inline documentation
-**Hot-reload** - Change behavior without recompiling Rust code
-**Double validation** - Nickel contracts + serde ensure correctness
## Configuration Composition Flow
![Configuration Composition](../diagrams/config-composition.svg)
The configuration system uses a **four-layer composition pattern**:
### Layer 1: Schema Contracts
**Location**: `schemas/contracts.ncl`
**Purpose**: Define types and validation rules using Nickel contracts.
**Example**:
```nickel
{
StorageType = [| 'filesystem, 'memory, 'surrealdb |],
StorageConfig = {
primary | StorageType
| doc "Primary storage backend"
| default = 'filesystem,
secondary | SecondaryStorageConfig
| doc "Optional secondary storage"
| default = { enabled = false },
},
}
```
**Benefits**:
- Enum validation (only valid storage types accepted)
- Required vs optional fields
- Default values for optional fields
- Documentation attached to types
### Layer 2: Defaults
**Location**: `schemas/defaults.ncl`
**Purpose**: Provide sensible base values for all configuration options.
**Example**:
```nickel
{
base = {
storage = {
primary = 'filesystem,
secondary = {
enabled = false,
type = 'surrealdb,
url = "ws://localhost:8000",
},
},
embeddings = {
enabled = true,
provider = 'fastembed,
model = "BAAI/bge-small-en-v1.5",
dimensions = 384,
},
} | contracts.KbConfig,
}
```
**Validated by**: `contracts.KbConfig` contract ensures defaults are valid.
### Layer 3: Mode Overlays
**Location**: `schemas/modes/{dev,prod,test}.ncl`
**Purpose**: Environment-specific optimizations and tuning.
#### Development Mode (`dev.ncl`)
Optimized for: Fast iteration, local development, debugging
```nickel
{
storage = {
primary = 'filesystem,
secondary = { enabled = false }, # No database overhead
},
embeddings = {
provider = 'fastembed, # Local, no API costs
},
sync = {
auto_index = false, # Manual control
},
}
```
#### Production Mode (`prod.ncl`)
Optimized for: Performance, reliability, scalability
```nickel
{
storage = {
secondary = { enabled = true }, # SurrealDB for scale
},
embeddings = {
provider = 'openai, # High-quality cloud embeddings
model = "text-embedding-3-small",
dimensions = 1536,
},
sync = {
auto_index = true,
debounce_ms = 300, # Fast response
},
}
```
#### Test Mode (`test.ncl`)
Optimized for: Fast tests, isolation, determinism
```nickel
{
storage = {
primary = 'memory, # Ephemeral, no disk I/O
},
embeddings = {
enabled = false, # Disable for speed
},
sync = {
auto_index = false,
debounce_ms = 0, # No delays in tests
},
}
```
### Layer 4: User Customizations
**Location**: `.kogral-config/core/kogral.ncl` or `.kogral-config/platform/{dev,prod,test}.ncl`
**Purpose**: Project-specific or deployment-specific overrides.
**Example** (user project config):
```nickel
let mode = import "../../schemas/modes/dev.ncl" in
let user_custom = {
graph = {
name = "my-project",
},
embeddings = {
provider = 'claude, # Override to use Claude
model = "claude-3-haiku-20240307",
},
query = {
similarity_threshold = 0.7, # Stricter threshold
},
} in
helpers.compose_config defaults.base mode user_custom
| contracts.KbConfig
```
## Composition Mechanism
The `helpers.ncl` module provides the composition function:
```nickel
{
# Recursively merge with override precedence
merge_with_override = fun base override => /* ... */,
# Compose three layers
compose_config = fun defaults mode_config user_custom =>
let with_mode = merge_with_override defaults mode_config in
merge_with_override with_mode user_custom,
}
```
**Merge behavior**:
- Records are merged recursively
- Override values take precedence over base values
- Arrays are not merged, override replaces base
- Null in override keeps base value
**Example merge**:
```nickel
base = { storage = { primary = 'filesystem }, embeddings = { enabled = true } }
override = { storage = { primary = 'memory } }
# Result: { storage = { primary = 'memory }, embeddings = { enabled = true } }
```
## Export to JSON
Once composed, the Nickel configuration is exported to JSON for Rust consumption:
```bash
nickel export --format json .kogral-config/core/kogral.ncl > .kogral-config/targets/kogral-core.json
```
**Output** (`.kogral-config/targets/kogral-core.json`):
```json
{
"graph": {
"name": "my-project",
"version": "1.0.0"
},
"storage": {
"primary": "memory",
"secondary": {
"enabled": false
}
},
"embeddings": {
"enabled": true,
"provider": "claude",
"model": "claude-3-haiku-20240307",
"dimensions": 768
}
}
```
## Rust Integration
The Rust code deserializes the JSON into typed structs using serde:
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct KbConfig {
pub graph: GraphConfig,
pub storage: StorageConfig,
pub embeddings: EmbeddingConfig,
pub templates: TemplateConfig,
pub query: QueryConfig,
pub mcp: McpConfig,
pub sync: SyncConfig,
}
impl KbConfig {
pub fn from_file(path: &Path) -> Result<Self> {
let json = std::fs::read_to_string(path)?;
let config: KbConfig = serde_json::from_str(&json)?;
Ok(config)
}
}
```
**Usage in kogral-core**:
```rust
let config = KbConfig::from_file(".kogral-config/targets/kogral-core.json")?;
// Config drives behavior
let storage: Box<dyn Storage> = match config.storage.primary {
StorageType::Filesystem => Box::new(FilesystemStorage::new(&config)?),
StorageType::Memory => Box::new(MemoryStorage::new()),
StorageType::SurrealDb => Box::new(SurrealDbStorage::new(&config).await?),
};
let embeddings: Box<dyn EmbeddingProvider> = match config.embeddings.provider {
EmbeddingProviderType::FastEmbed => Box::new(FastEmbedProvider::new()?),
EmbeddingProviderType::OpenAI => Box::new(RigEmbeddingProvider::openai(&config)?),
EmbeddingProviderType::Claude => Box::new(RigEmbeddingProvider::claude(&config)?),
EmbeddingProviderType::Ollama => Box::new(RigEmbeddingProvider::ollama(&config)?),
};
```
## Double Validation
Configuration is validated **twice**:
### 1. Nickel Contract Validation
At export time, Nickel validates:
- ✅ Types match contracts (e.g., `primary | StorageType`)
- ✅ Required fields are present
- ✅ Enums have valid values
- ✅ Nested structure is correct
**Error example**:
```text
error: contract broken by a value
┌─ .kogral-config/core/kogral.ncl:15:5
15│ primary = 'invalid,
│ ^^^^^^^^^^^^^^^^^^^ applied to this expression
= This value is not in the enum ['filesystem, 'memory, 'surrealdb]
```
### 2. Serde Deserialization Validation
At runtime, serde validates:
- ✅ JSON structure matches Rust types
- ✅ Field names match (with rename support)
- ✅ Values can be converted to Rust types
- ✅ Required fields are not null
**Error example**:
```rust
Error: missing field `graph` at line 1 column 123
```
## Benefits of Config-Driven Architecture
### 1. Zero Hardcoding
**Bad** (hardcoded):
```rust
// Hardcoded - requires recompilation to change
let storage = FilesystemStorage::new("/fixed/path");
let threshold = 0.6;
```
**Good** (config-driven):
```rust
// Config-driven - change via .ncl file
let storage = create_storage(&config)?;
let threshold = config.query.similarity_threshold;
```
### 2. Environment Flexibility
Same codebase, different behavior:
```bash
# Development
nickel export .kogral-config/platform/dev.ncl > targets/kogral-core.json
# → Filesystem storage, fastembed, no auto-sync
# Production
nickel export .kogral-config/platform/prod.ncl > targets/kogral-core.json
# → SurrealDB enabled, OpenAI embeddings, auto-sync
# Testing
nickel export .kogral-config/platform/test.ncl > targets/kogral-core.json
# → In-memory storage, no embeddings, isolated
```
### 3. Self-Documenting
Nickel contracts include inline documentation:
```nickel
StorageType = [| 'filesystem, 'memory, 'surrealdb |]
| doc "Storage backend type: filesystem (git-tracked), memory (ephemeral), surrealdb (scalable)",
```
IDEs can show this documentation when editing `.ncl` files.
### 4. Type-Safe Evolution
When adding new features:
1. Update contract in `contracts.ncl`
2. Add default in `defaults.ncl`
3. Export validates existing configs
4. Rust compilation validates deserialization
Breaking changes are caught **before runtime**.
### 5. Testability
Different test scenarios without code changes:
```nickel
# test-semantic-search.ncl
let test_config = defaults.base & {
embeddings = { enabled = true, provider = 'fastembed },
query = { similarity_threshold = 0.3 },
} in test_config
```
```rust
#[test]
fn test_semantic_search() {
let config = KbConfig::from_file("test-semantic-search.json")?;
// Config drives test behavior
}
```
## Configuration Discovery
KOGRAL tools automatically discover configuration:
1. **Check `.kogral-config/targets/kogral-core.json`** (pre-exported)
2. **Check `.kogral-config/core/kogral.ncl`** (export on-demand)
3. **Check environment variable `KOGRAL_CONFIG`**
4. **Fall back to embedded defaults**
```rust
impl KbConfig {
pub fn discover() -> Result<Self> {
if let Ok(config) = Self::from_file(".kogral-config/targets/kogral-core.json") {
return Ok(config);
}
if Path::new(".kogral-config/core/kogral.ncl").exists() {
// Export and load
let output = Command::new("nickel")
.args(["export", "--format", "json", ".kogral-config/core/kogral.ncl"])
.output()?;
return serde_json::from_slice(&output.stdout)?;
}
if let Ok(path) = std::env::var("KOGRAL_CONFIG") {
return Self::from_file(&path);
}
Ok(Self::default()) // Embedded defaults
}
}
```
## Integration with justfile
The `justfile` integrates configuration validation:
```just
# Validate all Nickel configs
nickel-validate-all:
@echo "Validating Nickel schemas..."
nickel typecheck schemas/contracts.ncl
nickel typecheck schemas/defaults.ncl
nickel typecheck schemas/helpers.ncl
nickel typecheck schemas/modes/dev.ncl
nickel typecheck schemas/modes/prod.ncl
nickel typecheck schemas/modes/test.ncl
nickel typecheck .kogral-config/core/kogral.ncl
# Export all platform configs
nickel-export-all:
@echo "Exporting platform configs to JSON..."
@mkdir -p .kogral-config/targets
nickel export --format json .kogral-config/platform/dev.ncl > .kogral-config/targets/kogral-dev.json
nickel export --format json .kogral-config/platform/prod.ncl > .kogral-config/targets/kogral-prod.json
nickel export --format json .kogral-config/platform/test.ncl > .kogral-config/targets/kogral-test.json
@echo "Exported 3 configurations to .kogral-config/targets/"
```
Usage:
```bash
just nickel::validate-all # Check configs are valid
just nickel::export-all # Generate JSON for all environments
```
## Best Practices
### 1. Never Hardcode
If it's behavior, it's config:
- Storage paths
- API endpoints
- Thresholds
- Timeouts
- Feature flags
- Provider selection
### 2. Use Modes for Environment Differences
Don't put environment-specific values in user config:
**Bad**:
```nickel
# user config with env-specific values
{
storage = {
url = if env == "prod" then "prod-url" else "dev-url" # Don't do this
}
}
```
**Good**:
```nickel
# modes/prod.ncl
{ storage = { url = "prod-url" } }
# modes/dev.ncl
{ storage = { url = "dev-url" } }
# user config is environment-agnostic
{ graph = { name = "my-project" } }
```
### 3. Document Complex Fields
Use Nickel's `doc` metadata:
```nickel
similarity_threshold | Number
| doc "Minimum cosine similarity (0-1) for semantic search matches. Higher = stricter."
| default = 0.6,
```
### 4. Validate Early
Run `nickel typecheck` in CI/CD before building Rust code.
### 5. Version Configs
Track `.ncl` files in git, ignore `.kogral-config/targets/*.json` (generated).
## See Also
- **Schema Reference**: [Configuration Schema](../config/schema.md)
- **User Guide**: [Configuration Guide](../config/overview.md)
- **ADR**: [Why Nickel vs TOML](adrs/001-nickel-vs-toml.md)
- **Examples**: `.kogral-config/core/kogral.ncl`, `.kogral-config/platform/*.ncl`