ruvector/scripts/research-gate/schema_validate.py
rUv a2326c0449
feat: ADR-280/281/282 — durable RVF metadata, role-aware embeddings, nightly research quality gate (#774)
Three ADRs implemented and hardened across five rounds of adversarial review, plus the fixes that review surfaced.

**ADR-280 — durable RVF metadata.** Delta-encoded generations with a snapshot every 32. The first implementation wrote a full snapshot per commit and replayed every one at open: 600 commits produced a 725 MiB file that could no longer be opened, with no repair path. Now 241 KB of META payload for the same workload, opening in ~4 ms. Review also closed: derive-children that could not be reopened, an 80-byte file driving a 512 MiB allocation, delete() rollback leaving in-memory tombstones that bricked the artifact, ten BufWriter sites discarding flush errors before sync_all, corrupt mid-chain deltas made unopenable (now recovers the longest valid prefix), and an ordering bug where recovery pruning committed without its re-anchoring snapshot so `rvf ingest` printed a repair warning and then destroyed the file.

**ADR-281 — role-aware embeddings.** Query/passage routing with an attested embedding-space identity. Review found the space id hashed CARGO_PKG_VERSION, so a routine version bump would have rejected every persisted corpus and invalidated every cache key — with the test suite structurally blind to it. Now keyed on a dedicated format revision with a golden-id test. Also: three constructors that failed unconditionally with ten unmigrated callers, prompt templates applied from the attested identity rather than hardcoded strings, and ApiEmbedding no longer bypassing templating.

**ADR-282 — nightly research quality gate.** Review found the gate had never completed a single run: the candidate checkout was shallow so its git diff always failed, and a jq quoting bug made the override path dead code. Check-run queries were unpaginated — on a real main commit 8 of 22 failures were invisible, so a red base could be certified green. Schemas are now load-bearing with a hashed dependency closure.

**CI note.** The two red checks are both pre-existing on main, not regressions from this branch: `Tests (core-and-rest)` routinely exceeds its 4-hour window, and `Hooks CI` has failed on main since 2026-08-02 (and in May) on `cp -r node_modules $GITHUB_WORKSPACE/npm/packages/cli/` in hooks-ci.yml — this branch's one-line version sync merely re-triggered its path filter. 72 checks pass.

Follow-ups filed and not blocking: #770, #771, #772.

🤖 Generated with [claude-flow](https://github.com/ruvnet/claude-flow)
2026-08-03 14:13:37 -03:00

97 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Offline JSON Schema validation for the ADR-282 research gate.
The gate consumes and emits documents that are hashed into an attested artifact
index, so their shape is a security boundary rather than a convenience. Every
schema under ``schemas/`` is loaded from disk and registered under its own
``$id``; the registry refuses to resolve anything it was not given, so a
candidate cannot redirect validation at a network-hosted schema.
"""
from __future__ import annotations
import json
import os
from functools import lru_cache
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from referencing import Registry, Resource
from referencing.exceptions import NoSuchResource
MANIFEST_SCHEMA = "research-manifest-v1.json"
RESULTS_SCHEMA = "research-results-v1.json"
REPORT_SCHEMA = "research-report-v1.json"
ARTIFACT_INDEX_SCHEMA = "research-artifact-index-v1.json"
OVERRIDE_SCHEMA = "research-override-v1.json"
BASE_GATE_SCHEMA = "research-base-gate-v1.json"
ATTESTATION_SUBJECT_SCHEMA = "research-attestation-subject-v1.json"
EMBEDDING_IDENTITY_SCHEMA = "embedding-space-identity-v1.json"
class SchemaValidationError(ValueError):
"""Raised when a document violates its declared JSON Schema."""
def schema_dir() -> Path:
override = os.environ.get("RESEARCH_GATE_SCHEMA_DIR")
if override:
return Path(override).resolve()
return Path(__file__).resolve().parents[2] / "schemas"
def _deny_remote(uri: str) -> Resource:
raise NoSuchResource(ref=uri)
@lru_cache(maxsize=None)
def _load_schema(name: str) -> dict[str, Any]:
path = schema_dir() / name
if not path.is_file():
raise SchemaValidationError(f"schema {name} is missing from {schema_dir()}")
contents = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(contents, dict):
raise SchemaValidationError(f"schema {name} must be a JSON object")
return contents
@lru_cache(maxsize=1)
def _registry() -> Registry:
resources = []
for path in sorted(schema_dir().glob("*.json")):
contents = _load_schema(path.name)
resource = Resource.from_contents(contents)
identifier = contents.get("$id")
resources.append((identifier or path.resolve().as_uri(), resource))
if not resources:
raise SchemaValidationError(f"no schemas found in {schema_dir()}")
return Registry(retrieve=_deny_remote).with_resources(resources)
@lru_cache(maxsize=None)
def _validator(name: str) -> Draft202012Validator:
schema = _load_schema(name)
Draft202012Validator.check_schema(schema)
return Draft202012Validator(
schema,
registry=_registry(),
format_checker=Draft202012Validator.FORMAT_CHECKER,
)
def validate_document(document: Any, schema_name: str) -> None:
"""Validate ``document`` against ``schema_name`` or raise SchemaValidationError."""
errors = sorted(_validator(schema_name).iter_errors(document), key=lambda item: list(item.absolute_path))
if not errors:
return
first = errors[0]
location = "/".join(str(part) for part in first.absolute_path) or "<document root>"
detail = f"{schema_name}: {location}: {first.message}"
if len(errors) > 1:
detail += f" (and {len(errors) - 1} further schema violation(s))"
raise SchemaValidationError(detail)
def known_schemas() -> list[str]:
return sorted(path.name for path in schema_dir().glob("*.json"))