# Validation Pipeline

The **Validation Pipeline** orchestrates deterministic execution of validations
against plan resources, enforcing required vs informational semantics and
producing aggregated summaries for the plan lifecycle.

## Overview

When a plan reaches the end of its Execute phase, the validation pipeline
runs all attached validations.  Each validation is modelled as a
`ValidationCommand` that specifies the target resource, execution mode,
arguments, and timeout.  Results are collected into `ValidationResult`
objects and aggregated into a `ValidationSummary`.

```
ValidationCommand(s) ──► ValidationPipeline.run() ──► ValidationSummary
```

## Ordering Rules

Validations are sorted deterministically before execution:

1. **Resource name** (alphabetical)
2. **Mode** (`informational` before `required` by enum value)
3. **Validation name** (alphabetical)

This ensures repeatable execution order regardless of the order in which
validations are registered or attached.

## Required vs Informational Behaviour

| Mode            | On Pass | On Fail                                      |
|-----------------|---------|----------------------------------------------|
| `required`      | OK      | Blocks: `all_required_passed` becomes `False` |
| `informational` | OK      | Logged but does **not** block                 |

- A `ValidationSummary` with `all_required_passed == False` signals that
  the plan should not proceed to the Apply phase.
- Informational failures are logged at INFO level for review but do not
  affect the `all_required_passed` property.

## Timeout Handling

Each `ValidationCommand` carries a `timeout_seconds` field (default 30 s).
Timeouts are enforced using daemon threads with `thread.join(timeout=N)`.
If a validation exceeds its timeout:

- The daemon thread is abandoned (not blocked on).
- The result is marked `passed=False` and `timed_out=True`.
- The error field contains `TimeoutError: exceeded <N>s`.
- The duration reflects the wall-clock time up to the timeout.

## Concurrency and Capture

The pipeline uses a `concurrent.futures.ThreadPoolExecutor` with a
configurable `max_workers` (default 4).  Validations are submitted
concurrently but results are re-sorted to maintain deterministic ordering.

Before execution, `sys.stdout` and `sys.stderr` are replaced with
thread-local stream wrappers (`_ThreadLocalStream`).  Each worker thread
captures its own output independently, avoiding cross-thread pollution.
The original streams are restored in a `finally` block after all
validations complete.

Setting `max_workers=1` forces purely sequential execution, which is
useful for debugging or when validations have interdependencies.

## Output Normalisation

Validation executors may return arbitrary output.  The pipeline normalises
all results to a consistent schema:

| Executor Returns       | Normalised To                               |
|------------------------|---------------------------------------------|
| `{"passed": True, ...}`| Used directly                               |
| Non-dict value         | `passed=False`, message describes the error |
| Missing `passed` key   | Defaults to `False`                         |
| Missing `message` key  | Defaults to `"No message provided"`         |
| Non-dict `data` value  | Wrapped in `{"raw": <value>}`               |

When an executor raises an exception, the result is recorded as
`passed=False` with a message describing the exception type and details
(e.g., `Validation raised RuntimeError: ...`).  The exception
information is preserved in the `error` field.

Captured stdout/stderr during validation execution is stored in the
result's `data` dict under `_captured_stdout` and `_captured_stderr` keys.

## Read-Only Resource Guard

If a validation targets a resource marked as read-only (via the
`read_only_resources` parameter), the pipeline **skips** the validation
and returns a passing result with a descriptive message.  A warning is
logged when this occurs.

## Integration with Plan Lifecycle

The `run_for_plan()` method extends `run()` by persisting the validation
summary into the plan's metadata dict:

```python
pipeline = ValidationPipeline(commands, executor)
summary = pipeline.run_for_plan(plan_metadata=plan.metadata)
# plan.metadata["validation_summary"] now contains the full summary
```

This allows downstream phases (Apply, CLI display) to inspect validation
results without re-running the pipeline.

## Models Reference

### ValidationCommand

| Field              | Type             | Default | Description                |
|--------------------|------------------|---------|----------------------------|
| `validation_name`  | `str`            | —       | Validation identifier      |
| `resource_id`      | `str`            | —       | Target resource ULID       |
| `resource_name`    | `str`            | —       | Human-readable name        |
| `mode`             | `ValidationMode` | —       | `required`/`informational` |
| `arguments`        | `dict`           | `{}`    | Executor arguments         |
| `timeout_seconds`  | `float`          | `30.0`  | Timeout in seconds         |

### ValidationResult

| Field              | Type             | Default | Description                |
|--------------------|------------------|---------|----------------------------|
| `validation_name`  | `str`            | —       | Validation identifier      |
| `resource_id`      | `str`            | —       | Resource ULID              |
| `resource_name`    | `str`            | —       | Human-readable name        |
| `mode`             | `ValidationMode` | —       | `required`/`informational` |
| `passed`           | `bool`           | —       | Pass/fail status           |
| `message`          | `str`            | —       | Result description         |
| `data`             | `dict \| None`   | `None`  | Structured data            |
| `duration_ms`      | `float`          | —       | Execution time (ms)        |
| `error`            | `str \| None`    | `None`  | Error message              |
| `timed_out`        | `bool`           | `False` | Timeout flag               |

### ValidationSummary

| Field                   | Type                    | Description              |
|-------------------------|-------------------------|--------------------------|
| `total`                 | `int`                   | Total validations        |
| `required_passed`       | `int`                   | Required passes          |
| `required_failed`       | `int`                   | Required failures        |
| `informational_passed`  | `int`                   | Informational passes     |
| `informational_failed`  | `int`                   | Informational failures   |
| `results`               | `list[ValidationResult]`| All results              |
| `all_required_passed`   | `bool` (property)       | True if no req. failures |

---

# Validation Pipeline — Apply Gating

## Overview

The validation apply gate enforces that attached validations pass before
the apply phase proceeds. Validations are attached to resources and run
automatically during the Execute-to-Apply transition. Required validation
failures block apply; informational failures are recorded but do not block.

## Attachment Model

### `ValidationAttachment`

Represents a validation bound to a resource with scope and mode.

| Field             | Type              | Description                          |
|-------------------|-------------------|--------------------------------------|
| `attachment_id`   | `str`             | Unique ULID for this attachment      |
| `validation_name` | `str`             | Name of the validation tool          |
| `resource_id`     | `str`             | Target resource ID                   |
| `resource_name`   | `str`             | Human-readable resource name         |
| `mode`            | `ValidationMode`  | `required` or `informational`        |
| `scope`           | `AttachmentScope` | `direct`, `project`, or `plan`       |
| `scope_target`    | `str`             | Project name or plan ID for scoping  |
| `arguments`       | `dict[str, Any]`  | Arguments passed to the validation   |

### `AttachmentScope`

| Value     | Description                                        |
|-----------|----------------------------------------------------|
| `direct`  | Always active for the resource                     |
| `project` | Active when resource is accessed via a project     |
| `plan`    | Active only for a specific plan                    |

When multiple scopes apply, the **union** of all applicable validations
is collected and run.

## Validation Modes

| Mode            | Behavior on Failure                              |
|-----------------|--------------------------------------------------|
| `required`      | Blocks apply, plan stays in Execute phase        |
| `informational` | Recorded in summary but does **not** block apply |

## Result Models

### `ApplyValidationResult`

Per-validation execution result.

| Field             | Type             | Description                         |
|-------------------|------------------|-------------------------------------|
| `attachment_id`   | `str`            | Which attachment was evaluated      |
| `validation_name` | `str`            | Validation tool name                |
| `resource_id`     | `str`            | Target resource                     |
| `mode`            | `ValidationMode` | Required or informational           |
| `passed`          | `bool`           | Whether the validation passed       |
| `message`         | `str`            | Result message                      |
| `data`            | `dict`           | Additional structured data          |
| `duration_ms`     | `int`            | Execution time in milliseconds      |
| `error`           | `str | None`     | Error message if execution failed   |
| `timed_out`       | `bool`           | Whether execution timed out         |

### `ApplyValidationSummary`

Aggregated results with gating decision.

**Properties:** `total`, `required_passed`, `required_failed`,
`informational_passed`, `informational_failed`, `all_required_passed`,
`is_empty`

**Methods:**
- `to_plan_metadata()` — Dict for plan `validation_summary` field
- `format_cli_output()` — Human-readable summary for CLI display

## Runner Interface

### `ValidationRunner` (ABC)

Abstract interface for executing a single validation:

```python
def run_validation(
    self,
    attachment: ValidationAttachment,
    context: dict[str, Any],
) -> ApplyValidationResult: ...
```

### `DefaultValidationRunner`

Default stub using text matching. Checks if the validation name or
argument values appear in the context. Production implementations
should invoke the actual validation tool via the tool execution pipeline.

## Apply Validation Gate

### `ApplyValidationGate`

Orchestrates the full validation-before-apply flow:

```python
gate = ApplyValidationGate(runner=DefaultValidationRunner())
summary = gate.run(plan_id, attachments, context)

if gate.should_block_apply(summary):
    reasons = gate.get_failure_reasons(summary)
    # Block apply, report reasons
else:
    # Proceed to apply
```

### Example Output

When apply is **blocked**:

```
Validation Gate: BLOCKED
  Required: 1 passed, 1 failed
  Informational: 0 passed, 0 failed
  Failures:
    - lint-check on res-001: 3 lint errors found
```

When apply is **allowed**:

```
Validation Gate: PASSED
  Required: 2 passed, 0 failed
  Informational: 1 passed, 0 failed
```

## Integration with Plan Metadata

The validation summary is stored in the plan's `validation_summary` field
via `ApplyValidationSummary.to_plan_metadata()`:

```python
{
    "total": 3,
    "required_passed": 2,
    "required_failed": 1,
    "informational_passed": 0,
    "informational_failed": 0,
    "all_required_passed": false,
    "evaluated_at": "2026-02-22T10:30:00+00:00",
    "results": [...]
}
```
