This release is a pre-release and may not be stable for production use.
SchemaIR
A portable, zero-dependency contract kernel for structured data.
SchemaIR lets you define schemas once and validate, relate, and interpret JSON data across TypeScript and Python under one normative protocol. Their normalized statuses, diagnostics and witnesses are compared on the shared conformance suite; this is not a claim of universal host-object or callback equivalence. It is designed for schema-driven contracts as first-class runtime artifacts.
Install note: npm
0.7.0-dev.4is published and includes core-4 and the TypeScriptschemair/authorsubpath, but enforces the earlier closed-head rule. Python0.7.0.dev4was not on PyPI as of 2026-10-15; published dev3 predates core-4. These are historical package facts, not current release claims. The prior unpublished dev5 pair and its core-5 acceptance predate current export, portable-1, and core-6 work. The internal, unpublished pair is now npm0.7.0-dev.6/ Python0.7.0.dev6, covering the core-6-only baseline. Exact verification scope and local artifact provenance are recorded in the implementation guide. No publication is authorized; use a checkout or locally prepared artifacts:
pnpm install --frozen-lockfile
pnpm --dir typescript build
python -m pip install -e ./python
# In a consumer project, install the built TypeScript package:
npm install /absolute/path/to/schemair/typescript
Supported wire identity: { "schemair": "core-6" } ·
Apache-2.0
- Normative core-6 contract
- Historical core-5 contract and wire schema
- Core-6 wire schema
- JSON Schema export API and implementation and local verification
- Public API reference
- Tutorial index
- Migration guidance
- General-union details and relation limitations
- Design philosophy
- Historical core-4 contract and wire schema
- Historical open tagged-union heads and core-4 compatibility decision
- Changelog
- Contributing
- Security
What SchemaIR is not
- Not a JSON Schema validator. SchemaIR implements its own core-6 protocol, not JSON Schema draft-07 or any other draft. Its Draft 2020-12 exporter is a conservative translation boundary, not JSON Schema import.
- Not a code generator or ORM. It produces validation results and witnesses, not classes, database queries, or serialization code.
- Not a documentation tool. Schemas are runtime artifacts, not annotations on existing code.
- Not a network service. No HTTP, no registry, no schema resolution over DNS. Document loading is a host-supplied synchronous or async callback.
- No canonical serialization in this release. Schemas round-trip through JSON but there is no normalized byte representation or hash.
What it does
| Capability | Description |
|---|---|
| Environment building | Load one or more schema documents into a validated, authenticated environment. Unknown fields, duplicate IDs, dangling references and unguarded cycles are all caught at creation time. |
| Payload validation | Validate any JSON value against a named definition, inline schema, or document root. Results are accepted, rejected (with diagnostic paths), or unknown (for example, exhausted budgets or unresolved metadata interpretation). |
| Relation checking | Prove that every value accepted by a source schema is also accepted by a target, or report uncertainty. On incompatibility, returns a complete JSON witness — a concrete counterexample. |
| Standard predicates | Call-local built-ins: email, integer, UUID/UUID v4, scalar bounds, array/record counts, deep uniqueness, and exact binary64 divisibility. See core-6 standard vocabulary. |
| Extension providers | Register call-local vocabulary callbacks. Two-phase dispatch (declaration check, then payload check), budget sharing, and full mutation isolation. |
| Metadata relation proofs | Optional exact-registration checkRelation callbacks, followed by a host metadataRelationFallback for remaining local metadata implications; no wire fields are added. |
| Core-6 applicators | Array contains-count bounds, record key schemas, and ordered bounded pattern/value schemas, conjoined with ordinary item/field/residual rules. |
| JSON Schema export | Draft 2020-12 structure and portable metadata, strict rejection or best-effort per-occurrence losses; formats remain annotations and only divisor 1 is emitted for multipleOf. |
Why SchemaIR
Most schema libraries are language-native — they validate against JSON Schema or a similar DSL, but the schema is interpreted differently in each runtime. SchemaIR takes a different approach: one normative spec, one wire format, two independent implementations with matching normalized results on the shared conformance vectors. The protocol is the contract; binding-specific result containers and host callback behavior remain distinct.
This matters when you need schemas to survive across:
- Service boundaries (TypeScript backend, Python ML pipeline)
- Storage and replay (schemas as immutable versioned artifacts)
- Static analysis (relation checking before deployment)
See docs/PHILOSOPHY.md for a fuller treatment.
Usage
Create an environment and validate a payload
import { createSchemaEnvironment, validateDocumentPayload } from 'schemair';
const env = createSchemaEnvironment([{
schemair: 'core-6',
id: 'user',
definitions: [{ name: 'Root', schema: {
kind: 'record',
fields: [
{ key: 'name', schema: { kind: 'primitive', name: 'string' } },
{ key: 'age', schema: { kind: 'primitive', name: 'number' } },
],
}}],
root: 'Root',
}]);
if (env.status === 'ok') {
const result = validateDocumentPayload(
env.environment,
{ document: 'user' },
{ name: 'Alice', age: 30 },
);
// result.status === 'accepted'
}
from schemair import create_environment, validate_document_payload, Limits
env = create_environment([{
"schemair": "core-6", "id": "user",
"definitions": [{"name": "Root", "schema": {
"kind": "record",
"fields": [
{"key": "name", "schema": {"kind": "primitive", "name": "string"}},
{"key": "age", "schema": {"kind": "primitive", "name": "number"}},
],
}}],
"root": "Root",
}], limits=Limits())
if env.status == "ok":
result = validate_document_payload(
env.value,
{"document": "user"},
{"name": "Alice", "age": 30},
)
# result.status == "accepted"
Author schemas in TypeScript
The optional schemair/author subpath constructs ordinary wire values;
no emitter or field() helper is needed. document() defaults to core-6; use
the current default or pass schemair: 'core-6' explicitly. Core-6 is required
for applicator fields.
The published npm 0.7.0-dev.4 includes this subpath, but predates core-6.
For the current contract, start at a local checkout with Node 22 and pnpm 10.6.5;
build and install the package itself (not a separate schemair/author package):
pnpm install --frozen-lockfile
pnpm --dir typescript build
# Then, in your consumer project:
npm install /absolute/path/to/schemair/typescript
import { createSchemaEnvironment } from 'schemair';
import { primitive, record, document } from 'schemair/author';
import type { Infer } from 'schemair/author';
const User = record({
name: primitive('string'),
age: { schema: primitive('number'), optional: true, description: 'Years' },
});
type UserValue = Infer<typeof User>; // { name: string; age?: number }
const userDocument = document({
schemair: 'core-6',
id: 'user',
definitions: { User },
root: 'User',
});
const env = createSchemaEnvironment([userDocument]);
Constructors cover all eleven schema kinds, full record/variant descriptions and
document definitions. Infer preserves literals, keys and tuple positions, but
references and broadly typed schemas remain unknown. It is static structural
inference, not validation: metadata is ignored, and open-record index signatures
overapproximate residual values by including explicit field types. Construction
is shallow and does not validate or authenticate inputs;
see authoring API.
General unions and admission profiles
The retired core-5 contract adds { kind: 'union', anyOf: [...] }: at least two complete schema nodes,
with a payload accepted when any operand accepts it. Current core-6 retains this union behavior.
Branches run in wire order without coercion; earlier branch rejections and non-limit unknowns are provisional
and are discarded if a later branch accepts. Rejection aggregates branch diagnostics
only after all branches reject. Exhaustion returns unknown / limit with bounded
context, not a branch issue tree, unless an independent enclosing definite rejection
was already established; a provisional branch rejection does not qualify. The default
restricted profile rejects every union node; general admits them. Current
documents use the explicit core-6 marker; core-5 and core-4 are historical and
rejected. The profile is fixed in the environment and inherited by inline
targets, payload validation, and relation checking; calls cannot override it.
TypeScript takes an environment options object; Python takes keyword-only
profile and limits parameters. Select the profile on create/load, not on
payload or relation calls:
import { createSchemaEnvironment, validateDocumentPayload } from 'schemair';
const choice = createSchemaEnvironment([{
schemair: 'core-6', id: 'choice',
definitions: [{ name: 'Root', schema: { kind: 'union', anyOf: [
{ kind: 'primitive', name: 'string' },
{ kind: 'primitive', name: 'number' },
] }}],
root: 'Root',
}], { profile: 'general' });
if (choice.status === 'ok') {
validateDocumentPayload(choice.environment, { document: 'choice' }, 42); // accepted
}
from schemair import create_environment, validate_document_payload
choice = create_environment([{
"schemair": "core-6", "id": "choice",
"definitions": [{"name": "Root", "schema": {"kind": "union", "anyOf": [
{"kind": "primitive", "name": "string"},
{"kind": "primitive", "name": "number"},
]}}],
"root": "Root",
}], profile='general')
if choice.status == "ok":
validate_document_payload(choice.value, {"document": "choice"}, 42) # accepted
For union relation behavior, every source
alternative must fit the complete target. Fitting one target alternative suffices,
but collective coverage can conservatively remain unknown; branch mismatches
alone do not prove incompatibility. See the relation tutorial
for proof and witness limitations.
Explicit targets
Every record field descriptor has key and schema, and may set optional: true
to permit absence. An omitted or false optional is required presence. The old
core-1 required descriptor is rejected; this does not rename unrelated JSON
Schema required keywords. Every call takes a target with a required document
home and at most one of name (named definition) or value (inline schema).
When neither target field is present, the document root is selected.
import { createSchemaEnvironment, validateDocumentPayload } from 'schemair';
const targets = createSchemaEnvironment([{
schemair: 'core-6', id: 'main',
definitions: [{ name: 'Address', schema: { kind: 'primitive', name: 'string' } }],
}]); // A definition library needs no root.
if (targets.status === 'ok') {
validateDocumentPayload(targets.environment, { document: 'main', name: 'Address' }, 'Paris');
validateDocumentPayload(targets.environment, {
document: 'main', value: { kind: 'primitive', name: 'string' },
}, 'Paris');
}
Standard predicates
Pass standard: true (Python: standard=True) to activate built-in semantic
predicates and scalar/container constraints. Algorithms and budget accounting
are specified across bindings rather than delegated to native regex or arithmetic.
import { createSchemaEnvironment, validateDocumentPayload } from 'schemair';
const ages = createSchemaEnvironment([{
schemair: 'core-6', id: 'ages',
definitions: [{ name: 'Root', schema: {
kind: 'primitive', name: 'number',
constraints: [
{ constraintPath: ['std', 'maximum'], args: 120 },
],
}}],
root: 'Root',
}]);
if (ages.status === 'ok') {
const result = validateDocumentPayload(ages.environment, { document: 'ages' }, 200, { standard: true });
// result.status === 'rejected'
}
from schemair import create_environment, validate_document_payload
ages = create_environment([{
"schemair": "core-6", "id": "ages",
"definitions": [{"name": "Root", "schema": {
"kind": "primitive", "name": "number",
"constraints": [{"constraintPath": ["std", "maximum"], "args": 120}],
}}],
"root": "Root",
}])
if ages.status == "ok":
result = validate_document_payload(ages.value, {"document": "ages"}, 200, standard=True)
# result.status == "rejected"
Portable-1 includes email, integer (finite integral binary64, not safe-integer
only), generic uuid and uuid/v4, numeric/string bounds,
minItems/maxItems, minProperties/maxProperties, argument-free
uniqueItems, and positive-divisor multipleOf. The latter is exact dyadic
binary64 arithmetic, not tolerance: 0.3 is not a multiple of 0.1.
Date/time, URI/IP, and std/pattern remain unsupported. See the
standard-predicate tutorial.
Core-6 and JSON Schema export
Core-6 provides array contains / minContains / maxContains, record propertyNames,
and ordered patternProperties: [{ pattern, schema }]. These are first-class schema edges,
not metadata arguments. Pattern matches do not exempt undeclared keys from
additionalFields. The
retired core-5 contract historically rejected these fields and remains negative
compatibility evidence. Current closures contain only core-6 documents, and inline targets
use their core-6 home document. No implicit upgrade or mixed-version current closure is
supported.
exportJsonSchema(env, target, options?) and
export_json_schema(env, target, *, mode='strict', standard=False, annotations=True, limits=None)
produce ordinary Draft 2020-12 JSON Schema. Strict mode rejects omitted or
nonportable assertions; best-effort returns a complete output with anchored
losses. UUID/email are annotation-only, and multipleOf export supports only
divisor 1 even though payload validation supports every positive finite
binary64 divisor. Results name structural, normalized-domain, or
best-effort fidelity; they are not a claim of arbitrary-validator equivalence.
See the API contract.
Sharing a budget across calls
Create a Budget from schemair/extension and pass it through sharedBudget to
make work and reference limits cumulative across payload and relation checks.
import { createSchemaEnvironment, validateDocumentPayload } from 'schemair';
import { Budget } from 'schemair/extension';
const budgetEnv = createSchemaEnvironment([{
schemair: 'core-6', id: 'number',
definitions: [{ name: 'Root', schema: { kind: 'primitive', name: 'number' } }],
root: 'Root',
}]);
const budget = new Budget({ maxWork: 100, maxReferenceExpansions: 10, maxDepth: 256, maxNodes: 1000 });
if (budgetEnv.status === 'ok') {
validateDocumentPayload(budgetEnv.environment, { document: 'number' }, 200, { sharedBudget: budget });
}
Python public calls accept per-call limits, not a shared budget.
Relation checking
checkDocumentRelation decides whether the source schema's value set is a
subset of the target's. On incompatibility it returns a complete JSON witness
that is simultaneously accepted by the source and rejected by the target.
import { createSchemaEnvironment, checkDocumentRelation } from 'schemair';
const versions = createSchemaEnvironment([
{ schemair: 'core-6', id: 'v1',
definitions: [{ name: 'Root', schema: { kind: 'primitive', name: 'string' } }],
root: 'Root' },
{ schemair: 'core-6', id: 'v2',
definitions: [{ name: 'Root', schema: { kind: 'literal', literal: 'ok' } }],
root: 'Root' },
]);
if (versions.status === 'ok') {
const rel = checkDocumentRelation(versions.environment, { document: 'v1' }, { document: 'v2' });
if (rel.status === 'incompatible') {
console.log(rel.issues[0].details?.witness); // source accepts; target rejects
}
}
from schemair import create_environment, check_document_relation
versions = create_environment([
{"schemair": "core-6", "id": "v1",
"definitions": [{"name": "Root", "schema": {"kind": "primitive", "name": "string"}}],
"root": "Root"},
{"schemair": "core-6", "id": "v2",
"definitions": [{"name": "Root", "schema": {"kind": "literal", "literal": "ok"}}],
"root": "Root"},
])
if versions.status == "ok":
rel = check_document_relation(versions.value, {"document": "v1"}, {"document": "v2"})
if rel.status == "incompatible":
print(rel.issues[0].details["witness"])
Relation statuses are assignable, incompatible, unknown, or rejected.
Only assignable proves containment. Applicable custom-provider checkRelation
callbacks and the host metadataRelationFallback can prove local metadata
implications; custom metadata is not inherently unsupported. Missing proofs,
unresolved interpretations, conservative collective union coverage, or exhausted
budgets can produce unknown; this is not incompatibility. Typed residuals are
checked structurally, while some open residual comparisons remain undecided.
See relation support and callback limits.
Extension providers
Register call-local vocabulary callbacks for custom predicates. Providers run in two phases (declaration check, then payload check) with budget sharing and mutation isolation — the host cannot alter the validated document.
import { createSchemaEnvironment, validateDocumentPayload } from 'schemair';
import type { PayloadProviderRegistration } from 'schemair';
function rangeArgs(args: unknown): { min: number; max: number } | undefined {
if (args === null || typeof args !== 'object' || Array.isArray(args)) return;
if (!('min' in args) || !('max' in args)) return;
const { min, max } = args;
if (typeof min === 'number' && Number.isFinite(min) &&
typeof max === 'number' && Number.isFinite(max) && min <= max) {
return { min, max };
}
}
const rangeProvider: PayloadProviderRegistration = {
vocabulary: 'constraint', path: ['ext', 'x-range'], carriers: ['number'],
checkDeclaration(node, application) {
return application.vocabulary === 'constraint' && rangeArgs(application.args)
? { status: 'valid' }
: { status: 'invalid', issues: [{ path: [], message: 'expected finite min ≤ max' }] };
},
checkPayload(node, application, value) {
const args = application.vocabulary === 'constraint' ? rangeArgs(application.args) : undefined;
return args && typeof value === 'number' && Number.isFinite(value) &&
value >= args.min && value <= args.max
? { status: 'accepted' }
: { status: 'rejected', issues: [{ path: [], message: 'out of range' }] };
},
};
const scores = createSchemaEnvironment([{
schemair: 'core-6', id: 'score',
definitions: [{ name: 'Root', schema: {
kind: 'primitive', name: 'number',
constraints: [{ constraintPath: ['ext', 'x-range'], args: { min: 0, max: 100 } }],
}}],
root: 'Root',
}]);
if (scores.status === 'ok') {
validateDocumentPayload(scores.environment, { document: 'score' }, 42, { providers: [rangeProvider] });
}
import math
from schemair import create_environment, validate_document_payload
def finite_number(value):
# bool is an int subclass in Python, but is not a JSON number.
return type(value) in (int, float) and math.isfinite(value)
def range_args(args):
if not isinstance(args, dict):
return None
lo, hi = args.get("min"), args.get("max")
if finite_number(lo) and finite_number(hi) and lo <= hi:
return lo, hi
return None
def check_range_declaration(node, app):
if range_args(app.get("args")) is not None:
return {"status": "valid"}
return {"status": "invalid", "issues": [{"path": [], "message": "expected finite min ≤ max"}]}
def check_range_payload(node, app, value):
bounds = range_args(app.get("args"))
if bounds is not None and finite_number(value) and bounds[0] <= value <= bounds[1]:
return {"status": "accepted"}
return {"status": "rejected", "issues": [{"path": [], "message": "out of range"}]}
range_provider = {
"vocabulary": "constraint", "path": ["ext", "x-range"], "carriers": ["number"],
"check_declaration": check_range_declaration, "check_payload": check_range_payload,
}
scores = create_environment([{
"schemair": "core-6", "id": "score",
"definitions": [{"name": "Root", "schema": {
"kind": "primitive", "name": "number",
"constraints": [{"constraintPath": ["ext", "x-range"], "args": {"min": 0, "max": 100}}],
}}],
"root": "Root",
}])
if scores.status == "ok":
validate_document_payload(scores.value, {"document": "score"}, 42, providers=[range_provider])
Schema node kinds
Core-6 defines eleven schema node kinds:
| Kind | What it matches |
|---|---|
primitive |
string, number, boolean, or null |
literal |
Exactly one non-null scalar value |
literalUnion |
Two or more distinct literals in one scalar domain |
record |
Object with typed fields; closed by default |
array |
Homogeneous sequence |
tuple |
Fixed-length, positionally typed sequence |
nullable |
null or any value matching the inner schema |
union |
Any value accepted by one of at least two schema operands; requires the general profile |
taggedUnion |
Discriminated union of record heads, keyed on a required string literal; heads may be open or typed-residual |
localRef |
Reference to a definition in the same document |
ref |
Reference to a named definition in any loaded document |
Guarantees
- No runtime dependencies. Neither package pulls in external code.
- Bounded execution. Every call has configurable work, depth, node, and reference budgets. Default limits are safe for untrusted input. Budgets cannot be silently exceeded.
- Snapshot isolation. Wire documents, targets, and payload values are snapshotted; later mutations cannot change the stored document environment. Callback functions/host I/O are not copied or sandboxed, and total host CPU, async wait time, and copying are not bounded by the kernel's work counter.
- No global schema registry. Environments are process-local authenticated instances. Persist raw documents and rebuild; clones/deserialized environments are not authenticated. There is no built-in network/document cache.
- Cross-binding parity. Both runtimes produce identical statuses, diagnostic codes, paths, and witness values on all conformance vectors, verified on every CI run.
Development
Requirements: Node 22, pnpm 10.6.5, Python 3.11.
pnpm install --frozen-lockfile
python -m venv .venv
.venv/bin/python -m pip install -r packaging/python-dev-requirements.txt
On Windows, use .venv\Scripts\python.exe instead of .venv/bin/python.
The commands below use the POSIX path; they do not depend on shell activation.
# Build/install the local TypeScript package, including schemair/author
pnpm --dir typescript build
# In a consumer project: npm install /absolute/path/to/schemair/typescript
# TypeScript
pnpm --dir typescript typecheck
pnpm --dir typescript test
# Python
PYTHONPATH=python .venv/bin/python -m unittest discover -s python/tests -v
# Cross-binding parity (conformance only)
node scripts/check-runtime-parity.mjs --profile core-6-c1
node scripts/check-runtime-parity.mjs --profile core-6-stable
See CONTRIBUTING.md for the full workflow.
Metadata
Release files for schemair 0.7.0.dev6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| schemair-0.7.0.dev6.tar.gz | 77.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| schemair-0.7.0.dev6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 146.6 kB
Release files / schemair-0.7.0.dev6.tar.gz
| Download URL | schemair-0.7.0.dev6.tar.gz |
|---|---|
| Size | 77.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5ac4f4c21dfd000c41997550814216ac51322027f319f87cbed7bf6bf97f5b69
|
|
BLAKE2b-256 checksum How to use checksums |
21fbde82b3616c9ebcfe1556214e15d57abc44cbfe4251376b3460b9b54a7b99
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|
Release files / schemair-0.7.0.dev6-py3-none-any.whl
| Download URL | schemair-0.7.0.dev6-py3-none-any.whl |
|---|---|
| Size | 69.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
12e4734fee602a02f6d2f82080ec4b1779816626662da78f5fc518ad619df149
|
|
BLAKE2b-256 checksum How to use checksums |
a39d1825a53a0be263f7c3d39bb0446752be43b280156d4c3fb7777f5998ccde
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|