540 lines
13 KiB
Markdown
540 lines
13 KiB
Markdown
# 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
|
|
|
|

|
|
|
|
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`
|