ISSUES CLOSED: #175
12 KiB
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:
- Resource name (alphabetical)
- Mode (
informationalbeforerequiredby enum value) - 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
ValidationSummarywithall_required_passed == Falsesignals 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_passedproperty.
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=Falseandtimed_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:
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` |
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 planvalidation_summaryfieldformat_cli_output()— Human-readable summary for CLI display
Runner Interface
ValidationRunner (ABC)
Abstract interface for executing a single validation:
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:
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():
{
"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": [...]
}