Makoto
Menu
Structural validation

JSON Schema answers “is this shaped correctly?” Nothing more.

Makoto publishes ordinary Draft 2020-12 schemas, served with CORS. Use them in editors, CI, and ingestion gates with the tools you already run: jq, check-jsonschema, Python jsonschema, pydantic, AJV, and Go. Every program on this page was run against the hosted schemas and the hosted fixture documents, and its real output is shown beneath it.

The code this page is about. Repeated from Python: jsonschema with a reference registry below.
uv run validate_schemas.py
valid    policy.json (trust-policy.schema.json)
valid    attestation.dsse.json (envelope.schema.json)
valid    statement.json (statement.schema.json)
valid    report.json (verification-report.schema.json)
invalid  broken-statement.json (statement.schema.json)
  $.predicate: Additional properties are not allowed ('bogus' was unexpected)
  $.predicate.inputs[0]: Additional properties are not allowed ('subject' was unexpected)
invalid  broken-policy.json (trust-policy.schema.json)
  $: 'limits' is a required property
  $.rules[0].minimumSignatures: 0 is less than the minimum of 1
LayerChecksDoes not check
Core schemaEnvelope, statement, predicate, profile reference, handoff, policy, bundle, and report shapes.Signatures or cross-resource meaning.
Organizational profileDeclared metadata or supported structured-artifact constraints.Whether the signer is authorized or the source is truthful.
Reference verifierStructure plus cryptography, authorization, graph, handoff, freshness, and artifacts.Data quality, confidentiality, compliance, or execution truth.

A valid document is not a verified one. A schema-valid policy can trust the wrong key. A schema-valid envelope can carry a signature over different bytes. Structure is one of sixteen checks the reference verifier runs, and it is the only one these tools perform. For the decision itself, see Complete receiver decision.

Which schema validates which document

All schemas live under one directory and are listed with their SHA-256 digests in the hosted catalog. Two practical details matter when you wire a validator. Signed statements and handoff manifests travel base64-encoded inside DSSE envelopes, so decode the payload before validating it. And the statement schema references sibling schemas by URL, so your validator must be allowed to resolve them.

Trust policy · trust-policy.schema.json
The receiver’s keys, rules, handoff requirements, and limits. Self-contained.
DSSE envelope · envelope.schema.json
Any *.dsse.json file: payload type, base64 payload, signatures. Self-contained.
Statement · statement.schema.json
The decoded envelope payload, with its origin or transform predicate. References origin, transform, and profile-reference.
Bundle index · bundle.schema.json
bundle.json at the root of a handoff bundle. Self-contained.
Handoff manifest · handoff.schema.json
The decoded payload of manifest.dsse.json. Self-contained.
Verification report · verification-report.schema.json
The JSON the verifier writes with --json. Self-contained.
Schema catalog · catalog.schema.json
A receiver-owned, digest-pinned catalog of profile schemas. Self-contained.
Profile reference · profile-reference.schema.json
A profile a statement or policy points to by identity and digest. Self-contained.
Dataset manifest · dataset-manifest.schema.json
A canonical manifest listing partition members and digests. Self-contained.

Sample documents

Every example below reads these files. They are the real outputs of the runnable fixture: the receiver’s policy, the final signed transformation, the verifier’s report for a tampered bundle, and one policy broken on purpose so the failure output is real too.

# Download real documents from the hosted runnable fixture.
set -euo pipefail
A=https://usemakoto.dev/demos/end-to-end/artifacts
curl -fsS -o policy.json "$A/receiver/policy.json"
curl -fsS -o attestation.dsse.json \
  "$A/positive-bundle/attestations/99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce.dsse.json"
curl -fsS -o report.json "$A/reports/mutated-final-data.json"

# The signed statement is the base64 payload inside the DSSE envelope.
jq -r .payload attestation.dsse.json | base64 --decode > statement.json

# A deliberately broken policy for the failure examples.
jq 'del(.limits) | .rules[0].minimumSignatures = 0' policy.json > broken-policy.json
ls -1 *.json

jq: a pre-flight, not a validator

jq is already in most CI images and needs no network. It cannot evaluate JSON Schema: no $ref, no additionalProperties, no nested constraint language. Use it to reject obviously wrong input fast, then run a real validator. The field names and bounds below are taken from trust-policy.schema.json; the envelope’s payload type is the one the verifier requires at its parse step.

# Fast field-presence pre-flight with jq. Not JSON Schema: no types, patterns,
# $ref resolution, or additionalProperties. Exit status 0 means "worth validating".
set -euo pipefail

policy_ok() {
  jq --exit-status '
    (keys - ["version","keys","rules","handoff","requiredProfiles","limits"] | length == 0)
    and has("version") and has("keys") and has("rules") and has("handoff")
    and has("requiredProfiles") and has("limits")
    and .version == "0.2"
    and (.keys | type == "object" and length >= 1)
    and (.keys | to_entries | all(.key | test("^sha256:[0-9a-f]{64}$")))
    and (.rules | type == "array" and length >= 1)
    and (.rules | all(.minimumSignatures >= 1 and (.authorizedKeyIds | length >= 1)))
  ' "$1" > /dev/null
}

envelope_ok() {
  jq --exit-status '
    (.payloadType == "application/vnd.in-toto+json")
    and (.payload | type == "string" and length > 0)
    and (.signatures | type == "array" and length >= 1)
    and (.signatures | all(has("keyid") and has("sig")))
  ' "$1" > /dev/null
}

for file in policy.json broken-policy.json; do
  if policy_ok "$file"; then echo "ok      $file"; else echo "REJECT  $file"; fi
done
if envelope_ok attestation.dsse.json; then echo "ok      attestation.dsse.json"; fi
bash preflight.sh
ok      policy.json
REJECT  broken-policy.json
ok      attestation.dsse.json

check-jsonschema: the command line

The check-jsonschema CLI runs through uvx with nothing installed, fetches remote $ref targets, and accepts many instance files at once.

uvx check-jsonschema --schemafile https://usemakoto.dev/schema/v0.2/trust-policy.schema.json policy.json
uvx check-jsonschema --schemafile https://usemakoto.dev/schema/v0.2/envelope.schema.json attestation.dsse.json
uvx check-jsonschema --schemafile https://usemakoto.dev/schema/v0.2/statement.schema.json statement.json
uvx check-jsonschema --schemafile https://usemakoto.dev/schema/v0.2/verification-report.schema.json report.json
# ok -- validation done   (printed once per command)

A failure names every location and exits 1:

uvx check-jsonschema --schemafile https://usemakoto.dev/schema/v0.2/trust-policy.schema.json broken-policy.json
Schema validation errors were encountered.
  broken-policy.json::$: 'limits' is a required property
  broken-policy.json::$.rules[0].minimumSignatures: 0 is less than the minimum of 1

Python: jsonschema with a reference registry

Draft202012Validator needs a referencing.Registry to follow the statement schema’s URL references. The retrieve hook below fetches them on demand and refuses anything outside the Makoto schema directory, so a hostile document cannot steer your validator at an arbitrary URL.

# /// script
# requires-python = ">=3.11"
# dependencies = ["jsonschema>=4.25,<5", "referencing>=0.36"]
# ///
"""Validate Makoto documents against the hosted Draft 2020-12 schemas."""

import json
import sys
import urllib.request
from pathlib import Path

from jsonschema import Draft202012Validator
from referencing import Registry, Resource

SCHEMA_BASE = "https://usemakoto.dev/schema/v0.2/"


def retrieve(uri: str) -> Resource:
    # Only follow references into the Makoto schema directory.
    if not uri.startswith(SCHEMA_BASE):
        raise ValueError(f"refusing to fetch {uri}")
    # The host answers urllib's default User-Agent with 403.
    request = urllib.request.Request(uri, headers={"User-Agent": "makoto-validate"})
    with urllib.request.urlopen(request, timeout=10) as response:
        return Resource.from_contents(json.load(response))


REGISTRY = Registry(retrieve=retrieve)


def validator_for(schema_name: str) -> Draft202012Validator:
    schema = REGISTRY.get_or_retrieve(SCHEMA_BASE + schema_name).value.contents
    Draft202012Validator.check_schema(schema)
    return Draft202012Validator(schema, registry=REGISTRY)


def validate(document: Path, schema_name: str) -> bool:
    instance = json.loads(document.read_text(encoding="utf-8"))
    errors = sorted(validator_for(schema_name).iter_errors(instance), key=lambda e: e.json_path)
    if not errors:
        print(f"valid    {document} ({schema_name})")
        return True
    print(f"invalid  {document} ({schema_name})")
    for error in errors:
        print(f"  {error.json_path}: {error.message}")
    return False


if __name__ == "__main__":
    checks = [
        ("policy.json", "trust-policy.schema.json"),
        ("attestation.dsse.json", "envelope.schema.json"),
        ("statement.json", "statement.schema.json"),  # follows $ref into transform + profile-reference
        ("report.json", "verification-report.schema.json"),
    ]
    results = [validate(Path(document), schema) for document, schema in checks]
    sys.exit(0 if all(results) else 1)
uv run validate_schemas.py
valid    policy.json (trust-policy.schema.json)
valid    attestation.dsse.json (envelope.schema.json)
valid    statement.json (statement.schema.json)
valid    report.json (verification-report.schema.json)

Against the broken documents, errors come back sorted by JSON path. The second case proves the $ref into the transform predicate is followed:

invalid  broken-statement.json (statement.schema.json)
  $.predicate: Additional properties are not allowed ('bogus' was unexpected)
  $.predicate.inputs[0]: Additional properties are not allowed ('subject' was unexpected)
invalid  broken-policy.json (trust-policy.schema.json)
  $: 'limits' is a required property
  $.rules[0].minimumSignatures: 0 is less than the minimum of 1

Python: pydantic for typed report ingestion

pydantic is not a JSON Schema validator, and hand-mirroring a schema this detailed would drift. Where it earns its place is the consumer side: turning stored verification reports into typed objects with the published vocabulary enforced, so downstream code cannot misspell a check identifier or treat a refusal without a primaryError as normal. Validate the full document against the schema as well when you persist it.

# /// script
# requires-python = ">=3.11"
# dependencies = ["pydantic>=2.8,<3"]
# ///
"""Parse stored Makoto verification reports into typed objects.

pydantic is not a JSON Schema validator. This model types the fields an
ingestion job reads and rejects reports whose decision, statuses, or check
identifiers fall outside the published vocabulary. Validate the full document
against verification-report.schema.json as well when you persist reports.
"""

import sys
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator

Decision = Literal["allow", "deny", "indeterminate"]
Status = Literal["pass", "fail", "indeterminate", "not_checked", "skipped"]
CheckId = Literal[
    "load-safely", "parse-strictly", "index-payloads", "core-schemas",
    "signatures", "authorization-thresholds", "metadata-profiles", "authorization",
    "graph-dependency-artifacts", "graph", "roots-and-heads", "completeness-anchor",
    "freshness-anchors", "artifact-bytes", "artifact-profiles", "decision",
]


class Digest(BaseModel):
    sha256: str = Field(pattern=r"^[0-9a-f]{64}$")


class Check(BaseModel):
    id: CheckId
    status: Status
    prerequisiteChecks: list[CheckId]

    @model_validator(mode="after")
    def skipped_names_its_cause(self) -> "Check":
        # Mirrors the schema: only a skipped check lists prerequisites.
        if (self.status == "skipped") != bool(self.prerequisiteChecks):
            raise ValueError("prerequisiteChecks must be non-empty exactly when status is skipped")
        return self


class Diagnostic(BaseModel):
    code: str = Field(pattern=r"^[EW]_[A-Z_]+$")
    step: int = Field(ge=0, le=14)
    causedByCheck: CheckId
    message: str
    context: dict[str, object]


class Summary(BaseModel):
    model_config = ConfigDict(extra="allow")
    statementsTotal: int | None
    statementsValid: int | None
    signaturesTotal: int | None
    signaturesValid: int | None


class VerificationReport(BaseModel):
    model_config = ConfigDict(extra="allow")  # the schema defines many more fields

    reportVersion: Literal["0.2"]
    decision: Decision
    primaryError: str | None
    evaluationTime: str
    policyDigest: Digest
    checks: list[Check] = Field(min_length=16, max_length=16)
    errors: list[Diagnostic]
    warnings: list[Diagnostic]
    summary: Summary

    @model_validator(mode="after")
    def decision_is_consistent(self) -> "VerificationReport":
        if self.decision == "allow" and (self.primaryError is not None or self.errors):
            raise ValueError("an allow cannot carry errors")
        if self.decision != "allow" and self.primaryError is None:
            raise ValueError("a refusal must name its primaryError")
        return self

    @property
    def not_passed(self) -> list[Check]:
        return [check for check in self.checks if check.status != "pass"]


if __name__ == "__main__":
    failed = False
    for path in sorted(Path(".").glob("reports/*.json")):
        try:
            report = VerificationReport.model_validate_json(path.read_bytes())
        except ValidationError as error:
            failed = True
            print(f"rejected {path}: {error.error_count()} problem(s)")
            print(error)
            continue
        print(f"{path.name}: {report.decision} {report.primaryError or ''}")
        for check in report.not_passed:
            print(f"  {check.id}: {check.status}")
    sys.exit(1 if failed else 0)
mkdir -p reports
for name in positive mutated-final-data statement-digest-mismatch edited-signed-metadata \
    removed-predecessor rewired-step private-schema-violation unauthorized-signer; do
  curl -fsS -o "reports/$name.json" \
    "https://usemakoto.dev/demos/end-to-end/artifacts/reports/$name.json"
done
uv run report_model.py
edited-signed-metadata.json: deny E_SIGNATURE_INVALID
  signatures: fail
  authorization-thresholds: fail
  authorization: fail
  graph: skipped
  roots-and-heads: skipped
  completeness-anchor: fail
  artifact-bytes: skipped
  artifact-profiles: skipped
mutated-final-data.json: deny E_ARTIFACT_DIGEST
  artifact-bytes: fail
positive.json: allow 
private-schema-violation.json: deny E_PROFILE_INVALID
  artifact-profiles: fail
removed-predecessor.json: deny E_PREDECESSOR_MISSING
  graph: fail
  completeness-anchor: fail
rewired-step.json: deny E_SIGNATURE_INVALID
  signatures: fail
  authorization-thresholds: fail
  authorization: fail
  graph: skipped
  roots-and-heads: skipped
  completeness-anchor: fail
  artifact-bytes: skipped
  artifact-profiles: skipped
statement-digest-mismatch.json: deny E_STATEMENT_DIGEST
  index-payloads: fail
  graph: skipped
  roots-and-heads: skipped
  completeness-anchor: fail
  artifact-bytes: skipped
  artifact-profiles: fail
unauthorized-signer.json: deny E_SIGNER_UNAUTHORIZED
  authorization-thresholds: fail
  authorization: fail
  graph: skipped
  roots-and-heads: skipped
  completeness-anchor: fail
  artifact-bytes: skipped
  artifact-profiles: skipped

That is all eight fixture reports, parsed and summarised. Note how far a signature failure reaches: authorization and completeness fail with it, and the graph, root, and artifact checks report skipped because nothing authenticated was left to test. A skipped check is never a pass.

Node.js and TypeScript: Ajv2020

Import AJV’s Draft 2020-12 build, give it a loadSchema function, and compile with compileAsync so referenced schemas are fetched once and cached on the instance. In TypeScript, use the named import import { Ajv2020 } from "ajv/dist/2020.js".

npm init -y && npm pkg set type=module
npm install ajv@8
// Validate Makoto documents against the hosted Draft 2020-12 schemas with AJV.
import { readFile } from "node:fs/promises";
import Ajv2020 from "ajv/dist/2020.js";

const SCHEMA_BASE = "https://usemakoto.dev/schema/v0.2/";

const ajv = new Ajv2020({
  allErrors: true,
  // The report schema narrows arrays inside if/then/else without repeating
  // "type"; strictTypes would log a warning for each of those locations.
  strictTypes: false,
  // Statement, origin, and transform schemas $ref sibling schemas by URL.
  loadSchema: async (uri) => {
    if (!uri.startsWith(SCHEMA_BASE)) throw new Error(`refusing to fetch ${uri}`);
    const response = await fetch(uri);
    if (!response.ok) throw new Error(`${uri}: HTTP ${response.status}`);
    return response.json();
  },
});

const compiled = new Map();
async function validatorFor(schemaName) {
  const id = SCHEMA_BASE + schemaName;
  if (!compiled.has(id)) {
    const existing = ajv.getSchema(id); // may already be loaded as someone's $ref
    compiled.set(id, existing ?? (await ajv.compileAsync(await ajv.opts.loadSchema(id))));
  }
  return compiled.get(id);
}

export async function validate(path, schemaName) {
  const validateFn = await validatorFor(schemaName);
  const instance = JSON.parse(await readFile(path, "utf8"));
  if (validateFn(instance)) {
    console.log(`valid    ${path} (${schemaName})`);
    return true;
  }
  console.log(`invalid  ${path} (${schemaName})`);
  for (const error of validateFn.errors) {
    console.log(`  ${error.instancePath || "/"} ${error.message}`, error.params);
  }
  return false;
}

const results = [];
for (const [path, schema] of [
  ["policy.json", "trust-policy.schema.json"],
  ["attestation.dsse.json", "envelope.schema.json"],
  ["statement.json", "statement.schema.json"],
  ["report.json", "verification-report.schema.json"],
  ["broken-policy.json", "trust-policy.schema.json"],
]) {
  results.push(await validate(path, schema));
}
process.exitCode = results.slice(0, 4).every(Boolean) && !results[4] ? 0 : 1;
node validate-schemas.mjs
valid    policy.json (trust-policy.schema.json)
valid    attestation.dsse.json (envelope.schema.json)
valid    statement.json (statement.schema.json)
valid    report.json (verification-report.schema.json)
invalid  broken-policy.json (trust-policy.schema.json)
  / must have required property 'limits' { missingProperty: 'limits' }
  /rules/0/minimumSignatures must be >= 1 { comparison: '>=', limit: 1 }

Why strictTypes: false. AJV’s strict mode wants "type" repeated beside every minItems and properties. The report schema narrows arrays inside if/then/else branches without repeating it, which is valid Draft 2020-12, so the default logs a warning for each location. Turning off only strictTypes keeps the rest of strict mode.

Go: santhosh-tekuri/jsonschema

Version 6 detects the draft from $schema and resolves references through a loader you control. This one fetches only from the Makoto schema directory.

go mod init example.com/makotovalidate
go get github.com/santhosh-tekuri/jsonschema/[email protected]
// Command makotovalidate checks Makoto documents against the hosted schemas.
package main

import (
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/santhosh-tekuri/jsonschema/v6"
)

const schemaBase = "https://usemakoto.dev/schema/v0.2/"

// makotoLoader resolves $ref URLs, but only inside the Makoto schema directory.
type makotoLoader struct{ client *http.Client }

func (l makotoLoader) Load(url string) (any, error) {
	if !strings.HasPrefix(url, schemaBase) {
		return nil, fmt.Errorf("refusing to fetch %s", url)
	}
	resp, err := l.client.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%s: %s", url, resp.Status)
	}
	return jsonschema.UnmarshalJSON(resp.Body)
}

func main() {
	compiler := jsonschema.NewCompiler()
	compiler.UseLoader(makotoLoader{client: &http.Client{Timeout: 10 * time.Second}})

	checks := [][2]string{
		{"policy.json", "trust-policy.schema.json"},
		{"attestation.dsse.json", "envelope.schema.json"},
		{"statement.json", "statement.schema.json"},
		{"report.json", "verification-report.schema.json"},
		{"broken-policy.json", "trust-policy.schema.json"},
	}
	invalid := 0
	for _, check := range checks {
		path, schemaName := check[0], check[1]
		schema, err := compiler.Compile(schemaBase + schemaName)
		if err != nil {
			fmt.Fprintln(os.Stderr, "compile:", err)
			os.Exit(2)
		}
		file, err := os.Open(path)
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(2)
		}
		instance, err := jsonschema.UnmarshalJSON(file)
		file.Close()
		if err != nil {
			fmt.Fprintln(os.Stderr, path, err)
			os.Exit(2)
		}
		if err := schema.Validate(instance); err != nil {
			invalid++
			fmt.Printf("invalid  %s (%s)\n%v\n", path, schemaName, err)
			continue
		}
		fmt.Printf("valid    %s (%s)\n", path, schemaName)
	}
	if invalid > 0 {
		os.Exit(1)
	}
}
go run .   # exits 1: the broken policy is invalid
valid    policy.json (trust-policy.schema.json)
valid    attestation.dsse.json (envelope.schema.json)
valid    statement.json (statement.schema.json)
valid    report.json (verification-report.schema.json)
invalid  broken-policy.json (trust-policy.schema.json)
jsonschema validation failed with 'https://usemakoto.dev/schema/v0.2/trust-policy.schema.json#'
- at '': missing property 'limits'
- at '/rules/0/minimumSignatures': minimum: got 0, want 1

Choosing a tool

jq
Shell pre-flight with no network. Field presence and simple bounds only; not JSON Schema.
check-jsonschema
CI and pre-commit. Full Draft 2020-12, follows remote references, many files per call.
Python jsonschema
Ingestion services and scripts that need every error with its JSON path.
pydantic
Typed consumption of stored reports. A subset by design; pair it with a schema validator.
Ajv2020
Node.js, TypeScript, and browser code. Compile once, validate many documents quickly.
Go jsonschema/v6
Static binaries, sidecars, and admission hooks with an explicit reference loader.

Pin what you trust. A schema fetched over the network at validation time is a moving input. In controlled builds, vendor the schema bytes and compare their SHA-256 with the catalog digest, or validate with the reference implementation, which resolves schemas by digest.

In CI

A pull-request job that checks every committed policy, envelope, and decoded statement. It is the cheap gate in front of the real one, and it passes actionlint.

name: makoto-structure

on:
  pull_request:
    paths:
      - "makoto/**.json"

permissions:
  contents: read

jobs:
  schemas:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false

      - uses: astral-sh/setup-uv@v6

      - name: Trust policy
        run: >-
          uvx check-jsonschema
          --schemafile https://usemakoto.dev/schema/v0.2/trust-policy.schema.json
          makoto/policy.json

      - name: Signed envelopes
        run: >-
          uvx check-jsonschema
          --schemafile https://usemakoto.dev/schema/v0.2/envelope.schema.json
          makoto/attestations/*.dsse.json

      - name: Decoded statements
        run: |
          set -euo pipefail
          mkdir -p "$RUNNER_TEMP/statements"
          for envelope in makoto/attestations/*.dsse.json; do
            jq -r .payload "$envelope" | base64 --decode \
              > "$RUNNER_TEMP/statements/$(basename "$envelope" .dsse.json).json"
          done
          uvx check-jsonschema \
            --schemafile https://usemakoto.dev/schema/v0.2/statement.schema.json \
            "$RUNNER_TEMP"/statements/*.json

Complete receiver decision

Schema validation never answers whether to accept data. The reference verifier does: it runs structure as one step of sixteen, then signatures over exact payload bytes, receiver authorization, profiles, graph continuity, handoff completeness, independent freshness anchors, and the bytes that actually arrived.

cd makoto   # a reviewed checkout, synced with uv sync --locked --dev
G=demos/end-to-end/generated
uv run makoto verify bundle "$G/positive-bundle" \
  --policy "$G/receiver/policy.json" \
  --schema-catalog "$G/receiver/catalog.json" \
  --expected-manifest sha256:e24aae77ba1374162edff49aa0ab1d8dd7e38c333e134a2128ad1598fec99fb7 \
  --expected-head sha256:99aee76db46a720405da6d0015d427c791b0d4e02367b9e2bc36ce82ae812bce \
  --expected-artifact "$G/receiver/expected-artifact.json" \
  --evaluation-time 2026-09-16T16:00:00Z \
  --json

Each language binding wraps this command with install steps, a tampered-bundle denial, and report handling: Bash, Python, JavaScript, TypeScript, and Go.

Two validators should agree. Help prove it.

If you run a validator not listed here, contribute its command and its output against the same fixture documents, including the broken ones.