Portable cross-field validation rules authored on Pydantic models and exported as self-describing JSON Schema resources.
uv add xvalidationsThe complementary validation engine is published as xvalidate on PyPI and npm.
Use an ordinary Pydantic model. A rule target is an RFC 9535 JSONPath, and a Path used anywhere in its assertion becomes a runtime $path operand.
from pydantic import BaseModel
from xvalidations import ValidationRule, XValidationContext, xvalidation
class Article(BaseModel):
tags: list[str]
primary_tag: str
@xvalidation(
id="primary-tag-exists", description="Primary tag must be present in tags."
)
@classmethod
def primary_tag_exists(cls, x: XValidationContext) -> ValidationRule:
return x.target(x.path.primary_tag).assert_schema({"enum": x.path.tags.each()})Exporting a validation-mode schema runs every effective rule factory and produces a deterministic resource:
schema = Article.model_json_schema(){
"$id": "urn:xvalidations:example.Article:<sha256>",
"$schema": "https://thearchitector.dev/xvalidations/schema.json",
"properties": {
"tags": {"items": {"type": "string"}, "type": "array"},
"primary_tag": {"type": "string"}
},
"x-validations": [
{
"id": "primary-tag-exists",
"description": "Primary tag must be present in tags.",
"target": "$.primary_tag",
"assert": {"enum": {"$path": "$.tags[*]"}}
}
]
}Literals stay inline. The authoring library never emits x-constants, and the contract rejects both legacy x-constants resources and $resolve markers.
Serialization-mode schemas are unchanged. Rule-free models also retain ordinary Pydantic schema behavior.
from xvalidate import XValidationError, xvalidate
try:
xvalidate(payload, schema)
except XValidationError as error:
assert error.errors[0].rule_id == "primary-tag-exists"
assert error.errors[0].path == "$.primary_tag"The engine validates the ordinary base schema first. It evaluates rules only for successful resource occurrences, attributes failures to selected targets, and reports error paths from the complete payload root.
Cardinality comes from JSONPath syntax, not the Pydantic field type.
- A path made only of name and index selectors is singular.
$.limits.upperinterpolates the selected JSON value. - Wildcards, slices, filters, selector lists, and recursive descent are non-singular.
$.tags[*]interpolates an array containing every match, including an empty array. - A missing singular operand cannot produce a value. If a rule selected targets, assertion construction fails those targets.
- Paths are evaluated independently. The engine does not zip, pair, or infer correlation between two result sets.
For example, a dynamic numeric bound uses a singular operand:
return x.target(x.path.value).assert_schema({"maximum": x.path.upper})Every ruled model is an independent Schema Resource. Its target and operand paths start from the current instance of that model, even when the resource is nested, recursive, or referenced more than once.
This establishes the correlation boundary. If a child rule must correlate value with allowed, both fields belong on the child model. A child rule cannot reach into its parent resource.
During validation-schema export, the authoring library uses Pydantic's generated schema to locate target and operand fragments. Compatibility is existential across anyOf and oneOf branches:
int | strmay be targeted bymaximumthrough its integer branch.- An operand
int | strmay supplymaximumthrough its integer branch. - A path that exists in at least one union branch is valid; a path unreachable in every branch is rejected.
- Compatibility is derived only from explicit
typedeclarations on the schema or throughallOf,anyOf, andoneOf; keyword-based type inference is not used. - Contradictory
allOftype declarations are rejected, as are schema forms whose possible JSON types cannot be determined.
Runtime behavior remains ordinary Draft 2020-12 behavior after interpolation. A valid maximum, for example, is inapplicable to a string target. If interpolation instead creates an invalid schema—such as a string-valued maximum—the rule fails each selected target.
Rule assertions support Draft 2020-12 validation keywords, allOf, anyOf, oneOf, not, if/then/else, contains, and the singleton x-uniqueBy assertion.
x-uniqueBy targets an array and applies its JSONPath projection to each array item. Duplicate failures are attributed to the duplicate item paths. A bare type keyword does not qualify as a root rule assertion; express ordinary type constraints on the Pydantic field.
Assertions deliberately exclude structural traversal (properties, items, and related keywords), references and definitions, annotations, and resource keywords. Put ordinary structural constraints on Pydantic fields. A rule must contain at least one Path or be an x-uniqueBy assertion; wholly static rules are rejected for the same reason.
The exact singleton object {"$path": "..."} is reserved contract syntax. Payload data selected by a path is returned opaquely, so marker-looking selected data is not interpreted a second time.
Inherited rule methods remain effective. Replacing one requires an explicit marker:
from typing import override
class Specialized(BaseArticle):
@xvalidation(id="specialized")
@classmethod
@override
def primary_tag_exists(cls, x: XValidationContext) -> ValidationRule:
return x.target(x.path.primary_tag).assert_schema({"enum": x.path.tags.each()})A class-local __get_pydantic_json_schema__ hook is composed once. A subclass that overrides that hook must delegate with super() so inherited X-Validations behavior runs.
| Helper | Example | JSONPath |
|---|---|---|
| Attribute or bracket key | x.path.primary_tag, x.path["field-id"] |
$.primary_tag, $["field-id"] |
.each() / x.wildcard() |
x.path.tags.each() |
$.tags[*] |
.at(index) / x.index(index) |
x.path.tags.at(0) |
$.tags[0] |
.slice(...) / x.slice(...) |
x.path.tags.slice(0, 10) |
$.tags[0:10] |
.where(...) / x.filter(...) |
x.path.items.where(x.this.kind == "text") |
$.items[?(@.kind == "text")] |
.select(*selectors) |
x.path.select(x.key("a"), x.key("b")) |
$["a","b"] |
.desc(...) |
x.path.desc("field_id") |
$..field_id |