# Story 5.18: Make health_check Optional in Filter Validator

## Status: Complete

## Priority: High

## Estimated Effort: Small (< 1 day)

## Dependencies: None

## Epic: Plugin System Consistency

---

## Context

The `validate_filter()` function in `fapilog/testing/validators.py` treats `health_check` as a required method, but the `BaseFilter` protocol defines it as optional with a default implementation that returns `True`.

This inconsistency means:

1. Filter plugins that don't implement `health_check` fail validation
2. The validator behavior doesn't match the protocol contract
3. Users following the protocol documentation may be confused by validation failures

---

## Problem Statement

### Current Protocol Definition

```python
# src/fapilog/plugins/filters/__init__.py

@runtime_checkable
class BaseFilter(Protocol):
    """Contract for filters that can drop or transform events before enrichment."""

    name: str

    async def start(self) -> None:
        """Initialize filter resources (optional)."""

    async def stop(self) -> None:
        """Release filter resources (optional)."""

    async def filter(self, event: dict) -> dict | None:
        """Return event to continue or None to drop."""

    async def health_check(self) -> bool:
        """Return True if healthy."""
        return True  # <-- Default implementation = optional
```

### Current Validator

```python
# src/fapilog/testing/validators.py

def validate_filter(filter_plugin: Any) -> ValidationResult:
    """Validate that a filter implements BaseFilter protocol correctly."""
    errors: list[str] = []
    warnings: list[str] = []

    # ...

    required_methods = ["start", "stop", "filter", "health_check"]  # <-- health_check treated as required!
    for method_name in required_methods:
        if not hasattr(filter_plugin, method_name):
            errors.append(f"Missing required method: {method_name}")
            continue
        method = getattr(filter_plugin, method_name)
        if not asyncio.iscoroutinefunction(method):
            errors.append(f"{method_name} must be async")
```

### The Inconsistency

| Method         | Protocol | Validator | Correct |
| -------------- | -------- | --------- | ------- |
| `name`         | Required | Required  | ✅      |
| `start`        | Optional | Required  | ❌      |
| `stop`         | Optional | Required  | ❌      |
| `filter`       | Required | Required  | ✅      |
| `health_check` | Optional | Required  | ❌      |

---

## Acceptance Criteria

### AC1: Fix health_check Validation

- [ ] Move `health_check` from required to optional in `validate_filter()`
- [ ] Missing `health_check` should generate a warning, not an error
- [ ] Existing `health_check` should still be validated as async

### AC2: Fix start/stop Validation

- [ ] Move `start` and `stop` to optional methods (matching other validators)
- [ ] Missing lifecycle methods should not cause validation failure
- [ ] Present lifecycle methods should still be validated as async

### AC3: Consistency Across Validators

- [ ] Ensure all five validators treat optional methods consistently
- [ ] Document which methods are required vs optional for each plugin type

### AC4: Update Documentation

- [ ] Update `docs/plugins/testing.md` to clarify required vs optional methods
- [ ] Update `docs/plugins/filters.md` to note health_check is optional

---

## Technical Design

### 1. Update validate_filter

```python
# src/fapilog/testing/validators.py

def validate_filter(filter_plugin: Any) -> ValidationResult:
    """Validate that a filter implements BaseFilter protocol correctly."""
    errors: list[str] = []
    warnings: list[str] = []

    # Check name attribute (required)
    if not hasattr(filter_plugin, "name"):
        errors.append("Missing required 'name' attribute")
    elif not isinstance(getattr(filter_plugin, "name", None), str):
        errors.append("'name' attribute must be a string")

    # Required methods
    required_methods = ["filter"]
    for method_name in required_methods:
        if not hasattr(filter_plugin, method_name):
            errors.append(f"Missing required method: {method_name}")
            continue
        method = getattr(filter_plugin, method_name)
        if not asyncio.iscoroutinefunction(method):
            errors.append(f"{method_name} must be async")

    # Optional lifecycle methods (warn if present but not async)
    optional_methods = ["start", "stop", "health_check"]
    for method_name in optional_methods:
        if hasattr(filter_plugin, method_name):
            method = getattr(filter_plugin, method_name)
            if not asyncio.iscoroutinefunction(method):
                warnings.append(f"{method_name} should be async")
        else:
            # Not having optional methods is fine, just informational
            pass

    # Check filter signature
    if hasattr(filter_plugin, "filter"):
        sig = inspect.signature(filter_plugin.filter)
        params = [param for name, param in sig.parameters.items() if name != "self"]
        if len(params) == 0:
            errors.append("filter must accept event parameter")

    return ValidationResult(
        valid=len(errors) == 0,
        plugin_type="BaseFilter",
        errors=errors,
        warnings=warnings,
    )
```

### 2. Review Other Validators for Consistency

Check and align the pattern across all validators:

```python
# Pattern for all validators:

VALIDATOR_REQUIREMENTS = {
    "sink": {
        "required": ["write"],
        "optional": ["start", "stop", "health_check", "write_serialized", "flush"],
    },
    "enricher": {
        "required": ["enrich"],
        "optional": ["start", "stop", "health_check"],
    },
    "redactor": {
        "required": ["redact"],
        "optional": ["start", "stop", "health_check"],
    },
    "processor": {
        "required": ["process"],
        "optional": ["start", "stop", "health_check", "process_many"],
    },
    "filter": {
        "required": ["filter"],
        "optional": ["start", "stop", "health_check"],
    },
}
```

---

## Test Plan

### Unit Tests

**test_filter_validator_optional_methods.py**

```python
import pytest
from fapilog.testing.validators import validate_filter


class MinimalFilter:
    """Filter with only required methods."""
    name = "minimal"

    async def filter(self, event: dict) -> dict | None:
        return event


class FullFilter:
    """Filter with all methods."""
    name = "full"

    async def start(self) -> None:
        pass

    async def stop(self) -> None:
        pass

    async def filter(self, event: dict) -> dict | None:
        return event

    async def health_check(self) -> bool:
        return True


def test_minimal_filter_is_valid():
    """A filter with only required methods should be valid."""
    result = validate_filter(MinimalFilter())
    assert result.valid, f"Errors: {result.errors}"
    assert len(result.errors) == 0


def test_full_filter_is_valid():
    """A filter with all methods should be valid."""
    result = validate_filter(FullFilter())
    assert result.valid, f"Errors: {result.errors}"


def test_filter_without_health_check_is_valid():
    """Missing health_check should not cause validation failure."""
    class NoHealthCheck:
        name = "no_health"
        async def filter(self, event: dict) -> dict | None:
            return event

    result = validate_filter(NoHealthCheck())
    assert result.valid
    # No error about missing health_check
    assert not any("health_check" in e for e in result.errors)


def test_filter_with_sync_health_check_warns():
    """Sync health_check should generate warning, not error."""
    class SyncHealthCheck:
        name = "sync_health"
        async def filter(self, event: dict) -> dict | None:
            return event
        def health_check(self) -> bool:  # Not async!
            return True

    result = validate_filter(SyncHealthCheck())
    assert result.valid  # Should still be valid
    assert any("health_check" in w for w in result.warnings)


def test_filter_missing_filter_method_is_invalid():
    """Missing filter method should cause validation failure."""
    class NoFilterMethod:
        name = "no_filter"
        async def start(self) -> None:
            pass

    result = validate_filter(NoFilterMethod())
    assert not result.valid
    assert any("filter" in e for e in result.errors)
```

---

## Documentation Updates

### docs/plugins/filters.md

Update contract section:

```markdown
## Contract

- `name: str` - **Required** plugin identifier
- `async filter(event: dict) -> dict | None` - **Required** filter logic
- `async start()/stop()` - Optional lifecycle hooks
- `async health_check() -> bool` - Optional health reporting (default: True)
```

### docs/plugins/testing.md

Add clarity about required vs optional:

```markdown
## Validators

Use validators to ensure plugins satisfy protocol contracts. Validators check:

**Required** (validation fails if missing):

- `name` attribute
- Core method (`write`, `enrich`, `redact`, `process`, `filter`)

**Optional** (warning if present but malformed):

- Lifecycle methods (`start`, `stop`)
- Health check (`health_check`)
- Fast-path methods (`write_serialized`, `process_many`)
```

---

## Backwards Compatibility

- Filters that previously passed validation will still pass
- Filters that previously failed due to missing `health_check` will now pass
- This is a relaxation of requirements, not a tightening

---

## Related Stories

- Story 5.2: Add filter plugin type
- Story 4.21: Plugin health check protocol
- Story 5.4: Plugin testing guide
