Files
2026-02-25 10:03:57 +00:00

301 lines
12 KiB
Markdown

# 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": [...]
}
```