Guide

Schema Validation for Baseline Metadata

Schema validation for baseline metadata is the gatekeeping stage of the capture pipeline: it verifies that every incoming query plan artifact structurally agrees with a versioned schema snapshot before that artifact is allowed to become part of a stored baseline. This stage does not execute queries, capture execution traces, or transform plan trees. It performs deterministic structural checks against an immutable schema manifest, so that downstream regression scoring always operates on artifacts whose table, column, index, and constraint references are known-good. Within Automated EXPLAIN Capture & Storage Workflows it sits between plan normalization and durable baseline storage, and it is the single point where schema drift is turned into an explicit, routable decision instead of a silent corruption.

Architectural Boundaries

The validator is intentionally the narrowest stage in the pipeline. It consumes exactly two inputs and emits exactly one of three outcomes, and it holds no mutable state of its own. This isolation is what makes it safe to run inline on the hot path of baseline promotion.

Upstream (consumes). The stage reads a normalized plan envelope produced by the canonicalization step described in Normalizing Query Plans for Cross-Engine Comparison. Each envelope carries a stable query fingerprint, a deterministic plan hash derived from the plan hashing algorithm, the declared schema_version, and the flattened set of table, column, and index references the plan touches. The second input is a schema manifest resolved from a version-controlled registry (a Git-backed object store or a schema-registry table), keyed by that same schema_version.

Downstream (emits). The validator publishes the envelope, annotated with a validation verdict, to one of three destinations: accepted records advance to durable baseline storage under the encryption and access rules covered in Security Boundaries for Baseline Data Storage; warning-tagged records go to a drift-monitoring queue for reconciliation; rejected records are quarantined to a dead-letter topic. Only accepted envelopes reach the centralized log sink described in Routing EXPLAIN ANALYZE Output to Centralized Logs, which keeps regression dashboards free of structurally invalid noise.

Isolation guarantees. The validator never opens a connection to a live production database, never issues DDL, never mutates baseline tables, and never performs statistical sampling. All inputs are treated as immutable JSON or Parquet artifacts. Because of this, a schema-registry outage or a malformed manifest can only ever stop promotion — it can never rewrite history that has already been committed.

The schema validator: two inputs, one stateless check, three routed outcomesA normalized plan envelope from the normalization stage and a schema manifest from the version-controlled registry both feed a single read-only, stateless validator that never touches a live database. It emits one of three verdicts: ACCEPT routes to the baseline store, WARN routes to the drift-reconciliation queue, and REJECT routes to the dead-letter quarantine.Normalized plan envelopefrom normalization stageSchema manifestversion-controlled registrySchemaValidatorread-only · statelessno live DB accessACCEPTWARNREJECTBaseline storeDrift-reconciliation queueDead-letter quarantine

Deterministic Routing & Schema Enforcement

Validation is governed by an explicit field contract and a fixed set of numeric thresholds. Nothing about the decision is heuristic: given the same envelope and the same manifest, the verdict is always identical and idempotent.

Envelope field contract

The incoming envelope is validated against a JSON Schema before any structural comparison runs. Malformed payloads are rejected at parse time so that later stages can assume a well-formed record.

JSON
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "PlanEnvelope",
  "type": "object",
  "required": ["query_hash", "schema_version", "plan_hash",
               "referenced_tables", "referenced_columns", "referenced_indexes"],
  "additionalProperties": false,
  "properties": {
    "query_hash":        { "type": "string", "pattern": "^[0-9a-f]{64}$" },
    "plan_hash":         { "type": "string", "pattern": "^[0-9a-f]{64}$" },
    "schema_version":    { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
    "referenced_tables": { "type": "array", "items": { "type": "string" } },
    "referenced_columns":{ "type": "array",
      "items": { "type": "object",
        "required": ["table", "column"],
        "properties": { "table": {"type":"string"}, "column": {"type":"string"} } } },
    "referenced_indexes":{ "type": "array", "items": { "type": "string" } }
  }
}

Routing thresholds

Each envelope must pass three deterministic checks. Routing is decided by hard numeric boundaries so that drift classification is never subjective.

CheckRuleRouting logic
Structural alignmentEvery table and column reference in the plan must exist in the manifest.missing_refs == 0 accept; missing_refs > 0 reject
Type & constraint consistencyColumn types, nullability, and PK/FK constraints must match the snapshot.type_promotions <= 2 and constraint_drops == 0 warn; constraint_drops > 0 reject
Index & partition mappingReferenced indexes and partition keys must be present and active.stale_refs == 0 accept; 1 <= stale_refs <= 3 warn; stale_refs > 3 reject

The final verdict is the most severe of the three per-check outcomes: any single reject dominates, otherwise any single warn dominates, otherwise accept.

Partition key for the baseline store

Accepted records are written to a partitioned store so that a schema epoch can be reasoned about — and reconciled — as a unit. The partition key is derived deterministically from the envelope, never from wall-clock capture time:

partition_key = f"{engine}/{schema_version}/{query_hash[:2]}"

Sharding on the first byte of query_hash yields 256 balanced partitions per schema_version, and grouping under schema_version means that invalidating an entire schema epoch after a migration is a single-prefix delete rather than a full table scan.

Production-Ready Implementation

The validator is best deployed as a stateless async consumer. The implementation below pulls envelopes from a queue, resolves the matching manifest from an asyncpg-backed schema registry (with a TTL cache), applies the deterministic checks, emits structured logs and OpenTelemetry spans/metrics, and returns a routed outcome. It is the happy path — fallback behaviour is covered in the failure section.

PYTHON
import time
from typing import Literal

import asyncpg
import structlog
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ValidationError
from opentelemetry import trace, metrics

log = structlog.get_logger("schema_validator")
tracer = trace.get_tracer("schema_validator")
meter = metrics.get_meter("schema_validator")

verdicts = meter.create_counter(
    "validation_results_total", description="Validation outcomes by status"
)
duration = meter.create_histogram(
    "validation_duration_seconds", description="End-to-end validation latency"
)
manifest_age = meter.create_gauge(
    "schema_manifest_age_seconds", description="Age of the resolved manifest"
)


class PlanEnvelope(BaseModel):
    query_hash: str
    plan_hash: str
    schema_version: str
    referenced_tables: list[str]
    referenced_columns: list[dict[str, str]]
    referenced_indexes: list[str]


class ValidationOutcome(BaseModel):
    status: Literal["ACCEPT", "WARN", "REJECT"]
    query_hash: str
    schema_version: str
    drift_score: float
    details: list[str]


class ManifestCache:
    """TTL cache over an asyncpg-backed schema registry."""

    def __init__(self, pool: asyncpg.Pool, ttl_seconds: int = 300) -> None:
        self._pool = pool
        self._ttl = ttl_seconds
        self._entries: dict[str, tuple[float, dict]] = {}

    async def resolve(self, schema_version: str) -> tuple[dict, float]:
        now = time.monotonic()
        cached = self._entries.get(schema_version)
        if cached and now - cached[0] < self._ttl:
            return cached[1], now - cached[0]

        row = await self._pool.fetchrow(
            "SELECT tables, active_indexes, constraints, fetched_at "
            "FROM schema_manifest WHERE schema_version = $1",
            schema_version,
        )
        if row is None:
            raise LookupError(f"no manifest for schema_version={schema_version}")

        manifest = {
            "tables": set(row["tables"]),
            "active_indexes": set(row["active_indexes"]),
            "constraints": row["constraints"],
        }
        self._entries[schema_version] = (now, manifest)
        return manifest, 0.0


_ENVELOPE_VALIDATOR = Draft202012Validator(schema=PLAN_ENVELOPE_SCHEMA)


def _classify(envelope: PlanEnvelope, manifest: dict) -> ValidationOutcome:
    details: list[str] = []

    missing = [t for t in envelope.referenced_tables if t not in manifest["tables"]]
    stale = [i for i in envelope.referenced_indexes if i not in manifest["active_indexes"]]
    constraint_drops = manifest["constraints"].get("dropped_since_capture", 0)

    if missing:
        details.append(f"missing_refs={len(missing)} tables={missing}")
        return ValidationOutcome(status="REJECT", query_hash=envelope.query_hash,
                                 schema_version=envelope.schema_version,
                                 drift_score=1.0, details=details)
    if constraint_drops > 0:
        details.append(f"constraint_drops={constraint_drops}")
        return ValidationOutcome(status="REJECT", query_hash=envelope.query_hash,
                                 schema_version=envelope.schema_version,
                                 drift_score=0.95, details=details)
    if len(stale) > 3:
        details.append(f"stale_refs={len(stale)} indexes={stale}")
        return ValidationOutcome(status="REJECT", query_hash=envelope.query_hash,
                                 schema_version=envelope.schema_version,
                                 drift_score=0.9, details=details)
    if stale:
        details.append(f"stale_refs={len(stale)} (soft drift)")
        return ValidationOutcome(status="WARN", query_hash=envelope.query_hash,
                                 schema_version=envelope.schema_version,
                                 drift_score=0.4, details=details)

    return ValidationOutcome(status="ACCEPT", query_hash=envelope.query_hash,
                             schema_version=envelope.schema_version,
                             drift_score=0.0, details=[])


async def validate_envelope(raw: dict, cache: ManifestCache) -> ValidationOutcome:
    started = time.perf_counter()
    with tracer.start_as_current_span("validate_baseline_metadata") as span:
        errors = sorted(_ENVELOPE_VALIDATOR.iter_errors(raw), key=lambda e: e.path)
        if errors:
            span.set_attribute("validation.status", "REJECT")
            verdicts.add(1, {"status": "REJECT", "reason": "malformed"})
            log.error("malformed_envelope", errors=[e.message for e in errors])
            return ValidationOutcome(status="REJECT", query_hash=raw.get("query_hash", "unknown"),
                                     schema_version=raw.get("schema_version", "unknown"),
                                     drift_score=1.0, details=["INVALID_PAYLOAD"])

        try:
            envelope = PlanEnvelope(**raw)
            manifest, age = await cache.resolve(envelope.schema_version)
        except (ValidationError, LookupError) as exc:
            span.set_attribute("validation.status", "REJECT")
            verdicts.add(1, {"status": "REJECT", "reason": "manifest"})
            log.error("manifest_resolution_failed", error=str(exc))
            return ValidationOutcome(status="REJECT", query_hash=raw.get("query_hash", "unknown"),
                                     schema_version=raw.get("schema_version", "unknown"),
                                     drift_score=1.0, details=[str(exc)])

        outcome = _classify(envelope, manifest)
        span.set_attribute("validation.status", outcome.status)
        span.set_attribute("validation.drift_score", outcome.drift_score)
        manifest_age.set(age, {"schema_version": envelope.schema_version})
        verdicts.add(1, {"status": outcome.status})
        duration.record(time.perf_counter() - started, {"status": outcome.status})
        log.info("validated", status=outcome.status, query_hash=envelope.query_hash,
                 schema_version=envelope.schema_version, drift_score=outcome.drift_score)
        return outcome

Thresholds & Alerting

The stage runs inline on baseline promotion, so its own latency and error budgets are tight. The following SLOs are the exact values to alert on; they are numeric, not advisory.

MetricTargetAlert boundary
Validation latency (p95)<= 25 mspage at > 50 ms p95 over 10 min
Validation latency (p99)<= 60 mspage at > 120 ms p99 over 10 min
Reject rate< 1% of throughputP3 at > 5% over 15 min
Manifest age<= 300 s (cache TTL)P2 at > 3600 s (registry sync lost)
Manifest fetch latency (p95)<= 15 mswarn at > 40 ms over 10 min
Malformed-envelope rate< 0.1%P3 at > 1% (upstream normalizer fault)

A spike in the reject rate almost always points upstream: a schema migration landed without a matching manifest publish, so validate before you enable the CI gate wired in Validating Schema Changes Against Baseline Metadata. Encode these boundaries as alert rules alongside the service:

YAML
groups:
  - name: schema-validator
    rules:
      - alert: ValidatorRejectRateHigh
        expr: |
          sum(rate(validation_results_total{status="REJECT"}[15m]))
            / sum(rate(validation_results_total[15m])) > 0.05
        for: 15m
        labels: { severity: page, priority: P3 }
        annotations:
          summary: "Baseline validator reject rate above 5% — check for an unpublished manifest"
      - alert: SchemaManifestStale
        expr: max(schema_manifest_age_seconds) > 3600
        for: 5m
        labels: { severity: page, priority: P2 }
        annotations:
          summary: "Schema manifest older than 3600s — registry sync likely broken"
      - alert: ValidatorLatencyP99
        expr: histogram_quantile(0.99, sum(rate(validation_duration_seconds_bucket[10m])) by (le)) > 0.12
        for: 10m
        labels: { severity: page, priority: P3 }
        annotations:
          summary: "Validator p99 latency above 120ms — inspect manifest cache hit ratio"

Failure Scenarios & Root Cause Analysis

Four failure modes account for nearly every validator incident. Each has a distinct signature in the metrics above.

  1. Unpublished manifest after a migration. Symptom: reject rate jumps to 20–100% for a single schema_version immediately after a deploy; logs show missing_refs on tables that clearly exist. Diagnosis: SELECT schema_version, fetched_at FROM schema_manifest ORDER BY fetched_at DESC LIMIT 5; — the new version is absent. Mitigation: re-run the manifest publisher for that version; the migration pipeline should block promotion until the manifest write is confirmed.

  2. Stale manifest cache (registry partition). Symptom: schema_manifest_age_seconds climbs past the TTL while reject and warn rates hold steady, then drift alerts fire late. Diagnosis: check registry reachability from the pod (pg_isready -h $REGISTRY_HOST) and the cache hit ratio in traces. Mitigation: the TTL cache serves last-known-good until max_manifest_age_seconds; beyond that, envelopes route to quarantine rather than being validated against outdated structure.

  3. Malformed envelope from the normalizer. Symptom: validation_results_total{status="REJECT",reason="malformed"} rises while structural rejects stay flat. Diagnosis: inspect the JSON Schema errors in the malformed_envelope log line; a changed field name or a non-hex plan_hash is typical. Mitigation: pin the envelope schema version between the normalizer and validator, and treat a schema bump as a coordinated deploy.

  4. Silent constraint drop counted as accept. Symptom: a downstream join-type regression appears in Detecting Join-Type Shifts in Execution Plans even though validation accepted the plan. Diagnosis: the manifest’s constraints.dropped_since_capture was never populated by the migration diff. Mitigation: make constraint-diff computation a required field of the manifest contract so a missing value fails validation instead of defaulting to zero.

Configuration Reference

All tuning knobs are externalized so thresholds and fallbacks can be adjusted without a redeploy.

Key / env varDefaultPurpose
VALIDATOR_MANIFEST_CACHE_TTL_SECONDS300Freshness window for a cached manifest before re-fetch.
VALIDATOR_MAX_MANIFEST_AGE_SECONDS3600Hard ceiling; past this, route to quarantine instead of validating.
VALIDATOR_MAX_MISSING_REFS0Missing table/column references tolerated before reject.
VALIDATOR_MAX_STALE_INDEXES_WARN3Stale index references allowed before escalating warn to reject.
VALIDATOR_MAX_TYPE_PROMOTIONS2Widening type changes tolerated as a warn.
VALIDATOR_CIRCUIT_BREAKER_FAILURES5Consecutive registry-fetch failures before the breaker opens.
VALIDATOR_REGISTRY_DSNasyncpg DSN for the schema-registry database.
VALIDATOR_QUARANTINE_TOPICdrift_quarantineDead-letter destination for rejected and bypassed envelopes.
YAML
validator:
  manifest_cache_ttl_seconds: 300
  routing_thresholds:
    max_missing_refs: 0
    max_stale_indexes_warn: 3
    max_stale_indexes_reject: 3
    max_type_promotions: 2
  fallback:
    enabled: true
    max_manifest_age_seconds: 3600
    circuit_breaker_failures: 5

Safe Fallback & Drift Quarantine

Network partitions, registry outages, or delayed DDL propagation can starve the validator of fresh manifests. These protocols keep the pipeline moving without ever compromising baseline integrity:

  1. Last-known-good cache. If the registry is unreachable, serve the most recently validated manifest up to max_manifest_age_seconds. Beyond that ceiling, route every payload to quarantine rather than risk validating against outdated structure.
  2. Circuit breaker. Track consecutive fetch failures. After circuit_breaker_failures (default 5), open the circuit and route all traffic to the drift_quarantine topic tagged validation_mode="BYPASS" — no envelope is silently accepted while the breaker is open.
  3. Quarantine & reconciliation. Rejected and bypassed payloads land in a dead-letter queue with full context. A background job periodically re-validates them against the latest manifest: records that now pass are promoted; the rest are archived with a schema_mismatch tag.
  4. Automated drift resolution. When soft-drift warnings accumulate for a fingerprint, trigger a diff against the DDL migration log. The reconciliation procedure — and the exact CI thresholds that gate a migration — are documented in Validating Schema Changes Against Baseline Metadata.

By enforcing strict isolation, deterministic routing, and disciplined fallback, this stage keeps baseline metadata a trustworthy foundation for the regression scoring that follows in Regression Detection & Rule Engines.

← Back to Automated EXPLAIN Capture & Storage Workflows