# Story 4.36: Plugin Validation at Load Time

**Status:** Complete  
**Priority:** High  
**Depends on:** 4.27 (Plugin Testing Utilities)  
**Effort:** 1 day

---

## Problem Statement

Story 4.27 created comprehensive plugin validators in `fapilog.testing`:

- `validate_sink()`
- `validate_enricher()`
- `validate_redactor()`
- `validate_processor()`

However, these validators are only used in tests. When plugins are loaded at runtime via `load_plugin()`, **no validation occurs**:

```python
# Current behavior in loader.py
def load_plugin(group: str, name: str, config: dict[str, Any] | None = None) -> Any:
    # ... lookup logic ...
    return _instantiate(cls, config)  # No validation!
```

### Impact

1. **Invalid plugins fail later**: A plugin missing `write()` method will crash on first log
2. **Confusing errors**: Users see `AttributeError` instead of clear "missing method" message
3. **No warning for missing `name`**: Protocol requires it but no check
4. **Third-party plugins aren't vetted**: Entry point plugins bypass all checks

---

## Goals

1. Optionally validate plugins at load time using existing validators
2. Emit clear diagnostics for validation failures
3. Allow strict mode (fail on invalid) or permissive mode (warn and continue)
4. Maintain backward compatibility (validation off by default initially)

---

## Design

### Validation Modes

```python
class ValidationMode(Enum):
    DISABLED = "disabled"   # No validation (current behavior)
    WARN = "warn"          # Validate and warn, but load anyway
    STRICT = "strict"      # Validate and reject invalid plugins
```

### Integration Point

Add validation in `_instantiate()` after plugin is created:

```python
def _instantiate(cls, config, *, group: str, validation_mode: ValidationMode) -> Any:
    try:
        instance = cls(**config) if config else cls()
    except Exception as exc:
        raise PluginLoadError(str(exc)) from exc
    
    if validation_mode != ValidationMode.DISABLED:
        _validate_plugin(instance, group, validation_mode)
    
    return instance
```

---

## Implementation Plan

### Phase 1: Add Validation to Loader

**File: `src/fapilog/plugins/loader.py`**

```python
from enum import Enum


class ValidationMode(Enum):
    DISABLED = "disabled"
    WARN = "warn"
    STRICT = "strict"


# Module-level default (can be overridden)
_validation_mode: ValidationMode = ValidationMode.DISABLED


def set_validation_mode(mode: ValidationMode) -> None:
    """Set the plugin validation mode."""
    global _validation_mode
    _validation_mode = mode


def _validate_plugin(instance: Any, group: str, mode: ValidationMode) -> bool:
    """Validate plugin against its protocol.
    
    Returns True if valid, False if invalid.
    Raises PluginLoadError in strict mode on invalid.
    """
    from ..testing.validators import (
        validate_sink,
        validate_enricher,
        validate_redactor,
        validate_processor,
    )
    
    validator_map = {
        "fapilog.sinks": validate_sink,
        "fapilog.enrichers": validate_enricher,
        "fapilog.redactors": validate_redactor,
        "fapilog.processors": validate_processor,
    }
    
    validator = validator_map.get(group)
    if validator is None:
        return True  # Unknown group, skip validation
    
    result = validator(instance)
    
    if not result.valid:
        plugin_name = getattr(instance, "name", type(instance).__name__)
        error_summary = "; ".join(result.errors)
        
        if mode == ValidationMode.STRICT:
            raise PluginLoadError(
                f"Plugin '{plugin_name}' failed validation: {error_summary}"
            )
        else:  # WARN mode
            try:
                diagnostics.warn(
                    "plugins",
                    "plugin validation failed",
                    plugin=plugin_name,
                    group=group,
                    errors=result.errors,
                    warnings=result.warnings,
                )
            except Exception:
                pass
        return False
    
    # Log warnings even for valid plugins
    if result.warnings and mode != ValidationMode.DISABLED:
        try:
            diagnostics.warn(
                "plugins",
                "plugin validation warnings",
                plugin=getattr(instance, "name", type(instance).__name__),
                warnings=result.warnings,
            )
        except Exception:
            pass
    
    return True


def load_plugin(
    group: str, 
    name: str, 
    config: dict[str, Any] | None = None,
    *,
    validation_mode: ValidationMode | None = None,
) -> Any:
    """Load a plugin by group and name.
    
    Args:
        group: Plugin group (e.g., "fapilog.sinks")
        name: Plugin name
        config: Plugin configuration
        validation_mode: Override default validation mode
    """
    mode = validation_mode if validation_mode is not None else _validation_mode
    # ... existing lookup logic ...
    return _instantiate(cls, config, group=group, validation_mode=mode)
```

### Phase 2: Settings Integration

**File: `src/fapilog/core/settings.py`**

```python
class PluginsSettings(BaseModel):
    enabled: bool = True
    allowlist: list[str] | None = None
    denylist: list[str] = []
    validation_mode: str = Field(
        default="disabled",
        description="Plugin validation mode: disabled, warn, strict"
    )
```

### Phase 3: Apply Settings at Init

**File: `src/fapilog/__init__.py`**

```python
def _apply_plugin_settings(settings: _Settings) -> None:
    """Apply plugin settings including validation mode."""
    mode_str = settings.plugins.validation_mode.lower()
    mode_map = {
        "disabled": _loader.ValidationMode.DISABLED,
        "warn": _loader.ValidationMode.WARN,
        "strict": _loader.ValidationMode.STRICT,
    }
    mode = mode_map.get(mode_str, _loader.ValidationMode.DISABLED)
    _loader.set_validation_mode(mode)


def get_logger(...):
    # ...
    _apply_plugin_settings(cfg_source)
    # ...
```

---

## Acceptance Criteria

- [x] `ValidationMode` enum with DISABLED, WARN, STRICT
- [x] `load_plugin()` validates when mode is not DISABLED
- [x] WARN mode logs errors but loads plugin anyway
- [x] STRICT mode raises `PluginLoadError` on invalid plugin
- [x] Settings control validation mode via `FAPILOG_PLUGINS__VALIDATION_MODE`
- [x] Validation uses existing `fapilog.testing` validators
- [x] Unknown plugin groups skip validation gracefully
- [x] Backward compatible: disabled by default

---

## Configuration

```bash
# Enable strict validation (fail on invalid plugins)
export FAPILOG_PLUGINS__VALIDATION_MODE=strict

# Enable warning-only validation
export FAPILOG_PLUGINS__VALIDATION_MODE=warn

# Disable validation (default)
export FAPILOG_PLUGINS__VALIDATION_MODE=disabled
```

---

## Test Plan

```python
def test_validation_strict_rejects_invalid():
    """Strict mode rejects plugins missing required methods."""
    class InvalidSink:
        name = "invalid"
        # Missing write(), start(), stop()
    
    register_builtin("fapilog.sinks", "invalid", InvalidSink)
    
    with pytest.raises(PluginLoadError, match="failed validation"):
        load_plugin(
            "fapilog.sinks", 
            "invalid",
            validation_mode=ValidationMode.STRICT,
        )


def test_validation_warn_logs_but_loads():
    """Warn mode loads plugin but emits diagnostics."""
    class InvalidSink:
        name = "invalid"
        # Missing methods
    
    register_builtin("fapilog.sinks", "invalid", InvalidSink)
    
    # Should not raise
    plugin = load_plugin(
        "fapilog.sinks",
        "invalid", 
        validation_mode=ValidationMode.WARN,
    )
    assert plugin is not None
    # Verify diagnostics were emitted (via mock)


def test_validation_disabled_skips_checks():
    """Disabled mode skips all validation."""
    # ... test ...
```

---

## Files Changed

| File | Change |
|------|--------|
| `src/fapilog/plugins/loader.py` | Add validation logic |
| `src/fapilog/core/settings.py` | Add `validation_mode` setting |
| `src/fapilog/__init__.py` | Apply settings |
| `tests/unit/test_plugin_validation_at_load.py` | New test file |

