# Configurable Plugin Pattern Specification

## Overview

A reusable configuration pattern for all Kollabor CLI plugins that provides:
- **Consistent config access** across all plugins
- **Hot-reload support** - config changes update running plugins
- **Validation with fallback** - invalid values fall back to safe defaults
- **Cached reads** - fast access without repeated config lookups
- **Breaking change detection** - schema versioning prevents migration errors

**Location:** `kollabor/plugins/base.py` (extended), `kollabor/config/validator.py` (new)

**Version:** 1.0.0

## Problem Statement

Current plugin configuration issues:
1. **No hot-reload** - config changes require restart
2. **Inconsistent access** - each plugin reads config differently
3. **No validation** - invalid values can crash plugins
4. **No fallback** - breaking changes require manual migration
5. **Stale values** - plugins cache config once at initialization

## Design Goals

| Goal | Description |
|------|-------------|
| **Backward Compatible** | Existing plugins work unchanged |
| **Opt-in Advanced Features** | Plugins can adopt features incrementally |
| **Zero Overhead by Default** | No impact if plugin doesn't use features |
| **Type Safety** | Runtime validation with clear error messages |
| **Schema Versioning** | Detect and handle breaking changes |

## Architecture

### Component Diagram

```
┌─────────────────────────────────────────────────────────────┐
│                         BasePlugin                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ CONFIG_SCHEMA = None  (opt-in to validation)        │   │
│  │                                                       │   │
│  │ cfg(key, default)     - safe config access           │   │
│  │ _load_config()        - validate + cache             │   │
│  │ _on_config_reload()   - hot-reload callback          │   │
│  │ _on_config_ready()    - subclass hook                │   │
│  │ _on_config_changed()  - subclass hook                │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                             │
                             │ inherits
                             ▼
┌─────────────────────────────────────────────────────────────┐
│                    AgentOrchestratorPlugin                   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ CONFIG_SCHEMA = {                                    │   │
│  │   "session_init_delay": {                            │   │
│  │     "default": 3.0,                                  │   │
│  │     "type": float,                                   │   │
│  │     "validator": lambda x: 0.5 <= x <= 10.0          │   │
│  │   },                                                 │   │
│  │   "poll_interval": {                                 │   │
│  │     "default": 2,                                    │   │
│  │     "type": int,                                     │   │
│  │     "min": 1, "max": 10                              │   │
│  │   }                                                  │   │
│  │ }                                                     │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
```

### Config Flow

```
┌────────────────┐     ┌────────────────┐     ┌────────────────┐
│ Config File    │────▶│ ConfigService  │────▶│ ConfigLoader   │
│ ~/.kollabor... │     │ (watchdog)     │     │ + merge        │
└────────────────┘     └────────────────┘     └────────┬───────┘
                                                       │
                                              File change detected
                                                       │
                              register_reload_callback()│
                                                       ▼
┌────────────────┐     ┌────────────────┐     ┌────────────────┐
│ Plugin Instance│◀────│ BasePlugin     │◀────│ _on_config_    │
│ (new values)   │     │ _load_config() │     │ reload()       │
└────────────────┘     └────────────────┘     └────────────────┘
```

## API Specification

### BasePlugin Extensions

```python
# kollabor/plugins/base.py

class BasePlugin:
    """Enhanced base with configurable pattern."""

    # NEW: Schema definition (opt-in)
    CONFIG_SCHEMA: dict[str, SchemaItem] | None = None
    CONFIG_VERSION: int = 1  # Schema version for migration

    def __init__(self, name: str, event_bus, renderer, config):
        self.name = name
        self.event_bus = event_bus
        self.renderer = renderer
        self.config = config

        # NEW: Config cache and error tracking
        self._config_cache: dict[str, Any] = {}
        self._config_error: str | None = None
        self._reload_registered: bool = False

    # NEW: Safe config access
    def cfg(self, key: str, default: Any = None) -> Any:
        """Get config value with fallback to schema default.

        Args:
            key: Config key (without "plugins.{name}." prefix)
            default: Fallback if not found in schema or config

        Returns:
            Config value, schema default, or provided default
        """
        # Try cached value first
        if key in self._config_cache:
            return self._config_cache[key]

        # Try schema default
        if self.CONFIG_SCHEMA and key in self.CONFIG_SCHEMA:
            return self.CONFIG_SCHEMA[key]["default"]

        # Try direct config lookup
        if self.config:
            return self.config.get(f"plugins.{self.name}.{key}", default)

        return default

    # NEW: Load and validate config
    def _load_config(self) -> bool:
        """Load config with validation and fallback.

        Returns:
            True if load succeeded, False if fell back to defaults
        """
        if not self.CONFIG_SCHEMA:
            return True  # No schema, skip validation

        success = True
        for key, schema in self.CONFIG_SCHEMA.items():
            # Read from config
            full_key = f"plugins.{self.name}.{key}"
            value = self.config.get(full_key, schema["default"])

            # Validate
            if not self._validate_value(key, value, schema):
                logger.warning(
                    f"Invalid config value for {full_key}: {value}, "
                    f"using default: {schema['default']}"
                )
                value = schema["default"]
                success = False

            self._config_cache[key] = value

        self._config_error = None if success else "Some values were invalid"
        return success

    # NEW: Validation helper
    def _validate_value(self, key: str, value: Any, schema: dict) -> bool:
        """Validate a config value against its schema.

        Args:
            key: Config key
            value: Value to validate
            schema: Schema definition for this key

        Returns:
            True if valid, False otherwise
        """
        # Type check
        expected_type = schema.get("type")
        if expected_type and not isinstance(value, expected_type):
            return False

        # Custom validator
        validator = schema.get("validator")
        if validator and not validator(value):
            return False

        # Range check
        if "min" in schema and value < schema["min"]:
            return False
        if "max" in schema and value > schema["max"]:
            return False

        # Choice check
        if "choices" in schema and value not in schema["choices"]:
            return False

        return True

    # NEW: Hot-reload callback
    def _on_config_reload(self) -> None:
        """Called when config file changes (registered by initialize)."""
        old_cache = self._config_cache.copy()
        success = self._load_config()

        # Notify on changes
        for key, schema in (self.CONFIG_SCHEMA or {}).items():
            if old_cache.get(key) != self._config_cache.get(key):
                callback = schema.get("on_change")
                if callback:
                    try:
                        getattr(self, callback)(
                            old_cache.get(key),
                            self._config_cache.get(key)
                        )
                    except Exception as e:
                        logger.error(f"Config change callback failed: {e}")

        # Subclass hook
        self._on_config_changed(success)

    # NEW: Subclass hooks
    def _on_config_ready(self) -> None:
        """Called after initial config load. Override in subclass."""
        pass

    def _on_config_changed(self, success: bool) -> None:
        """Called after hot-reload. Override in subclass.

        Args:
            success: True if values were valid, False if fell back
        """
        pass

    # ENHANCED: Initialize with reload support
    async def initialize(self, args: argparse.Namespace = None, **kwargs) -> None:
        """Initialize plugin with hot-reload support.

        Override _on_config_ready() for post-config initialization.
        """
        # Get dependencies
        if "event_bus" in kwargs:
            self.event_bus = kwargs["event_bus"]
        if "config" in kwargs:
            self.config = kwargs["config"]

        # Register reload callback (one-time)
        if self.config and not self._reload_registered:
            self.config.register_reload_callback(self._on_config_reload)
            self._reload_registered = True

        # Load and validate initial config
        self._load_config()

        # Call subclass hook
        await self._on_config_ready()

    # EXISTING: Unchanged methods for backward compatibility
    async def register_hooks(self) -> None:
        pass

    async def shutdown(self) -> None:
        pass

    @staticmethod
    def get_default_config() -> dict[str, Any]:
        return {}
```

### Schema Definition

```python
CONFIG_SCHEMA = {
    "key_name": {
        # Required
        "default": Any,           # Fallback value

        # Optional - Type validation
        "type": type,             # int, float, str, bool, list, etc.

        # Optional - Range validation (numeric)
        "min": int | float,
        "max": int | float,

        # Optional - Choice validation
        "choices": list[Any],     # Value must be in this list

        # Optional - Custom validator
        "validator": Callable[[Any], bool],

        # Optional - Change callback
        "on_change": str,         # Method name to call on change

        # Optional - Documentation
        "help": str,              # Description for UI
        "category": str,          # Group for config UI
        "advanced": bool,         # Hide in basic UI
    }
}
```

## Usage Examples

### Basic Plugin (Opt-in)

```python
class MyPlugin(BasePlugin):
    CONFIG_SCHEMA = {
        "enabled": {
            "default": True,
            "type": bool,
            "help": "Enable the plugin"
        },
        "timeout": {
            "default": 30,
            "type": int,
            "min": 1,
            "max": 300,
            "help": "Timeout in seconds"
        },
        "log_level": {
            "default": "INFO",
            "choices": ["DEBUG", "INFO", "WARNING", "ERROR"],
            "on_change": "_handle_log_level_change"
        }
    }

    async def _on_config_ready(self):
        """Called after config is loaded and validated."""
        self.timeout = self.cfg("timeout")  # Safe access
        self.enabled = self.cfg("enabled")

        # Initialize with config values
        if self.enabled:
            await self._start_service()

    def _handle_log_level_change(self, old: str, new: str):
        """Called when log_level changes."""
        logger.info(f"Log level changed: {old} -> {new}")
        # Update logger dynamically
        self._update_logger(new)

    def _on_config_changed(self, success: bool):
        """Called after any config change."""
        if not success:
            logger.warning("Some config values were invalid, using defaults")
        # Reinitialize service with new values
        if self.cfg("enabled"):
            await self._restart_service()
```

### Component with Lazy Config

```python
class AgentOrchestrator:
    """Component that reads config dynamically."""

    def __init__(
        self,
        config: ConfigService | None = None,
        session_init_delay: float = 3.0,
        kollab_init_delay: float = 4.0,
        # ... other params
    ):
        self.config = config
        self._session_init_delay = session_init_delay
        self._kollab_init_delay = kollab_init_delay

    @property
    def session_init_delay(self) -> float:
        """Lazy read from config if available."""
        if self.config:
            return self.config.get(
                "plugins.agent_orchestrator.session_init_delay",
                self._session_init_delay
            )
        return self._session_init_delay

    @session_init_delay.setter
    def session_init_delay(self, value: float):
        self._session_init_delay = value
```

### Legacy Plugin (No Changes)

```python
class OldStylePlugin(BasePlugin):
    """Works unchanged - backward compatible."""

    async def initialize(self, args, **kwargs):
        # Still works, but no hot-reload
        self.enabled = self.config.get("plugins.old_style.enabled", True)
```

## Migration Guide

### Step 1: Add CONFIG_SCHEMA

```python
# Before
class MyPlugin(BasePlugin):
    async def initialize(self, args, **kwargs):
        self.timeout = self.config.get("plugins.my_plugin.timeout", 30)

# After
class MyPlugin(BasePlugin):
    CONFIG_SCHEMA = {
        "timeout": {
            "default": 30,
            "type": int,
            "min": 1,
            "max": 300
        }
    }

    async def _on_config_ready(self):
        self.timeout = self.cfg("timeout")
```

### Step 2: Use cfg() for Access

```python
# Before
value = self.config.get("plugins.my_plugin.key", default)

# After
value = self.cfg("key", default)
```

### Step 3: Add Change Handlers (Optional)

```python
CONFIG_SCHEMA = {
    "api_key": {
        "default": "",
        "on_change": "_handle_api_key_change"
    }
}

def _handle_api_key_change(self, old: str, new: str):
    """Reconnect when API key changes."""
    self._reconnect_client()
```

## Testing

### Unit Tests

```python
# tests/unit/test_configurable_plugin.py

class MockPlugin(BasePlugin):
    CONFIG_SCHEMA = {
        "timeout": {"default": 30, "type": int, "min": 1, "max": 100},
        "enabled": {"default": True, "type": bool}
    }

def test_cfg_returns_default():
    plugin = MockPlugin("mock", None, None, None)
    assert plugin.cfg("timeout") == 30
    assert plugin.cfg("enabled") == True

def test_cfg_returns_cached():
    plugin = MockPlugin("mock", None, None, None)
    plugin._config_cache["timeout"] = 50
    assert plugin.cfg("timeout") == 50

def test_validation_fallback():
    plugin = MockPlugin("mock", None, None, None)
    plugin.config = MockConfig({"plugins.mock.timeout": 999})
    plugin._load_config()
    assert plugin.cfg("timeout") == 30  # Fallback to default
```

### Integration Tests

```python
# tests/integration/test_config_hot_reload.py

async def test_config_reload_updates_plugin():
    """Test that plugin receives config changes."""
    plugin = MyPlugin("test", event_bus, renderer, config)

    # Change config file
    config.set("plugins.test.timeout", 60)
    config.save_config()

    # Plugin should have new value
    await asyncio.sleep(0.1)  # Wait for file watcher
    assert plugin.cfg("timeout") == 60
```

## Implementation Phases

### Phase 1: Core Infrastructure
- [ ] Extend `BasePlugin` with config cache and `cfg()` method
- [ ] Add `_load_config()` with validation
- [ ] Add `_on_config_reload()` callback registration
- [ ] Add `_on_config_ready()` and `_on_config_changed()` hooks

### Phase 2: Schema Validator
- [ ] Create `kollabor/config/validator.py`
- [ ] Implement type checking
- [ ] Implement range validation
- [ ] Implement choice validation
- [ ] Implement custom validator support

### Phase 3: Migrate Plugins
- [ ] Migrate `AgentOrchestratorPlugin`
- [ ] Migrate `HookMonitoringPlugin`
- [ ] Migrate `MCPPlugin`
- [ ] Migrate remaining plugins

### Phase 4: Documentation
- [ ] Update plugin development guide
- [ ] Add migration examples
- [ ] Document schema format
- [ ] Create troubleshooting guide

## Edge Cases

### Config Service Unavailable
```python
def cfg(self, key: str, default: Any = None) -> Any:
    if self.config:
        return self.config.get(f"plugins.{self.name}.{key}", default)
    return self.CONFIG_SCHEMA.get(key, {}).get("default", default)
```

### Breaking Changes
```python
# Schema versioning
CONFIG_VERSION = 2

# On reload, detect version mismatch
if cached_version != CONFIG_VERSION:
    logger.warning("Config schema version changed, using defaults")
    self._load_config()  # Revalidate with new schema
```

### Concurrent Access
```python
# Config cache is read-only after init
# No locking needed for reads
# Writes only happen in _on_config_reload (single-threaded via event loop)
```

## Open Questions

1. **Nested configs?** - Should we support `plugins.my_plugin.subsection.key`?
   - Decision: No, keep flat. Use dotted key names like `section_key`.

2. **Secret handling?** - Should API keys be masked in logs?
   - Decision: Yes, add `"secret": true` to schema.

3. **Config migration?** - Should we auto-migrate old config formats?
   - Decision: Use `CONFIG_VERSION` to detect and warn only.

4. **Dynamic schema?** - Should plugins be able to modify schema at runtime?
   - Decision: No, schema is static for safety.

## Success Criteria

- [ ] All new plugins use `CONFIG_SCHEMA`
- [ ] 80% of existing plugins migrated
- [ ] Config changes propagate without restart
- [ ] Invalid values fall back safely
- [ ] Zero regression in existing functionality
- [ ] Tests cover all validation paths
