Repository: planetlabs/gpq
Pull request #23 (2023-05-23 19:05:14)

Generate a validation report
This change reworks the `gpq validate` command to generate a validation report.  By default, a text report is generated.  The `--format json` argument can be used to generate a JSON report.


Patch:
diff --git a/Makefile b/Makefile
index 5b891db..30e19a4 100644
--- a/Makefile
+++ b/Makefile
@@ -11,4 +11,16 @@ wasm: web/wasm_exec.js ## Build wasm
 	@GOOS=js GOARCH=wasm go build -tags noasm -o web/gpq.wasm ./cmd/wasm/.
 
 web/wasm_exec.js: ## Copy the wasm_exec.js file
-	@cp "$$(go env GOROOT)/misc/wasm/wasm_exec.js" web
\ No newline at end of file
+	@cp "$$(go env GOROOT)/misc/wasm/wasm_exec.js" web
+
+.PHONY: test
+test: ## Run the tests
+	@go test ./...
+
+.PHONY: fixtures
+fixtures: ## Run validator tests and update expected fixtures to match actuals
+	@go test ./internal/validator/... >/dev/null || true
+	@for f in ./internal/validator/testdata/*/actual.json; \
+			do \
+				cp "$$f" "$$(echo "$$f" | sed s/actual.json/expected.json/)"; \
+			done;
diff --git a/cmd/gpq/describe.go b/cmd/gpq/describe.go
index 545345a..89eac4e 100644
--- a/cmd/gpq/describe.go
+++ b/cmd/gpq/describe.go
@@ -24,8 +24,8 @@ import (
 )
 
 type DescribeCmd struct {
-	Input  string `arg:"" name:"input" help:"Path to a GeoParquet file." type:"existingfile"`
-	Pretty bool   `help:"Add newlines and indentation to the JSON output."`
+	Input    string `arg:"" name:"input" help:"Path to a GeoParquet file." type:"existingfile"`
+	Unpretty bool   `help:"No newlines or indentation in the JSON output."`
 }
 
 func (c *DescribeCmd) Run() error {
@@ -56,7 +56,7 @@ func (c *DescribeCmd) Run() error {
 	}
 
 	encoder := json.NewEncoder(os.Stdout)
-	if c.Pretty {
+	if !c.Unpretty {
 		encoder.SetIndent("", "  ")
 		encoder.SetEscapeHTML(false)
 	}
diff --git a/cmd/gpq/main.go b/cmd/gpq/main.go
index de05547..382b869 100644
--- a/cmd/gpq/main.go
+++ b/cmd/gpq/main.go
@@ -27,6 +27,6 @@ var CLI struct {
 
 func main() {
 	ctx := kong.Parse(&CLI)
-	err := ctx.Run()
+	err := ctx.Run(ctx)
 	ctx.FatalIfErrorf(err)
 }
diff --git a/cmd/gpq/validate.go b/cmd/gpq/validate.go
index 712cad3..acfc0ec 100644
--- a/cmd/gpq/validate.go
+++ b/cmd/gpq/validate.go
@@ -16,25 +16,125 @@ package main
 
 import (
 	"context"
-	"errors"
+	"encoding/json"
 	"fmt"
 	"os"
+	"strings"
 
+	"github.com/alecthomas/kong"
+	"github.com/fatih/color"
 	"github.com/planetlabs/gpq/internal/validator"
 )
 
 type ValidateCmd struct {
-	Input string `arg:"" name:"input" help:"Input file." type:"existingfile"`
+	Input        string `arg:"" name:"input" help:"Input file." type:"existingfile"`
+	MetadataOnly bool   `help:"Only run rules that apply to file metadata and schema (no data will be scanned)."`
+	Unpretty     bool   `help:"No colors in text output, no newlines and indentation in JSON output."`
+	Format       string `help:"Report format.  Possible values: ${enum}." enum:"text, json" default:"text"`
 }
 
-func (c *ValidateCmd) Run() error {
-	validator := validator.New()
-	err := validator.Validate(context.Background(), c.Input)
-
+func (c *ValidateCmd) Run(ctx *kong.Context) error {
+	v := validator.New(c.MetadataOnly)
+	report, err := v.Validate(context.Background(), c.Input)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "%#v\n", err)
-		return errors.New("validation failed")
+		return err
+	}
+
+	valid := true
+	for _, check := range report.Checks {
+		if !check.Passed {
+			valid = false
+			break
+		}
+	}
+
+	if c.Format == "json" {
+		if err := c.formatJSON(report); err != nil {
+			return err
+		}
+	} else {
+		if err := c.formatText(report); err != nil {
+			return err
+		}
+	}
+
+	if !valid {
+		ctx.Kong.Exit(1)
+	}
+	return nil
+}
+
+func (c *ValidateCmd) formatJSON(report *validator.Report) error {
+	encoder := json.NewEncoder(os.Stdout)
+	if !c.Unpretty {
+		encoder.SetIndent("", "  ")
+		encoder.SetEscapeHTML(false)
+	}
+
+	return encoder.Encode(report)
+}
+
+func (c *ValidateCmd) formatText(report *validator.Report) error {
+	passed := 0
+	failed := 0
+	unrun := 0
+	for _, check := range report.Checks {
+		if !check.Run {
+			unrun++
+		} else if check.Passed {
+			passed++
+		} else {
+			failed++
+		}
 	}
 
+	summaries := []string{
+		fmt.Sprintf("Passed %d check%s", passed, maybeS(passed)),
+	}
+	if failed > 0 {
+		summaries = append(summaries, fmt.Sprintf("failed %d check%s", failed, maybeS(failed)))
+	}
+	if unrun > 0 {
+		summaries = append(summaries, fmt.Sprintf("%d check%s not run", unrun, maybeS(unrun)))
+	}
+
+	if c.Unpretty {
+		color.NoColor = true
+	}
+
+	fmt.Printf("\nSummary: %s.\n\n", strings.Join(summaries, ", "))
+	if report.MetadataOnly {
+		skipped := len(validator.DataScanningRules())
+		color.Yellow("Metadata and schema checks only.  Skipped %d data scanning check%s.\n\n", skipped, maybeS(skipped))
+	}
+
+	passPrefix := " ✓"
+	failPrefix := " ✗"
+	unrunPrefix := " !"
+	reasonPrefix := "   ↳"
+	for _, check := range report.Checks {
+		if !check.Run {
+			color.Yellow("%s %s", unrunPrefix, check.Title)
+			color.Yellow("%s %s", reasonPrefix, "not checked")
+			continue
+		}
+
+		if check.Passed {
+			color.Green("%s %s", passPrefix, check.Title)
+			continue
+		}
+
+		color.Red("%s %s", failPrefix, check.Title)
+		color.Red("%s %s", reasonPrefix, check.Message)
+	}
+	fmt.Println()
+
 	return nil
 }
+
+func maybeS(count int) string {
+	if count == 1 {
+		return ""
+	}
+	return "s"
+}
diff --git a/go.mod b/go.mod
index 1dc17e6..c44cc5f 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,10 @@ go 1.20
 
 require (
 	github.com/alecthomas/kong v0.7.1
+	github.com/fatih/color v1.15.0
 	github.com/paulmach/orb v0.9.2
 	github.com/santhosh-tekuri/jsonschema/v5 v5.3.0
+	github.com/segmentio/encoding v0.3.5
 	github.com/segmentio/parquet-go v0.0.0-20230427215636-d483faba23a5
 	github.com/stretchr/testify v1.8.3
 )
@@ -15,12 +17,14 @@ require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/klauspost/compress v1.15.9 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/mattn/go-runewidth v0.0.9 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 	github.com/pierrec/lz4/v4 v4.1.9 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/segmentio/encoding v0.3.5 // indirect
+	github.com/segmentio/asm v1.1.3 // indirect
 	go.mongodb.org/mongo-driver v1.11.4 // indirect
-	golang.org/x/sys v0.1.0 // indirect
+	golang.org/x/sys v0.6.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 7a95b49..40596b3 100644
--- a/go.sum
+++ b/go.sum
@@ -7,6 +7,8 @@ github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -26,6 +28,11 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
@@ -41,6 +48,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 h1:uIkTLo0AGRc8l7h5l9r+GcYi9qfVPt6lD4/bhmzfiKo=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
 github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
 github.com/segmentio/encoding v0.3.5 h1:UZEiaZ55nlXGDL92scoVuw00RmiRCazIEmvPSbSvt8Y=
 github.com/segmentio/encoding v0.3.5/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM=
@@ -82,8 +90,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
diff --git a/internal/geojson/geojson.go b/internal/geojson/geojson.go
index 3e07294..6b9fd91 100644
--- a/internal/geojson/geojson.go
+++ b/internal/geojson/geojson.go
@@ -20,11 +20,8 @@ import (
 	"fmt"
 	"io"
 	"reflect"
-	"strings"
 
 	"github.com/paulmach/orb"
-	"github.com/paulmach/orb/encoding/wkb"
-	"github.com/paulmach/orb/encoding/wkt"
 	orbjson "github.com/paulmach/orb/geojson"
 	"github.com/planetlabs/gpq/internal/geoparquet"
 	"github.com/segmentio/parquet-go"
@@ -47,8 +44,6 @@ func NewFeatureWriter(writer io.Writer, metadata *geoparquet.Metadata, schema *p
 	return featureWriter, nil
 }
 
-var stringType = parquet.String().Type()
-
 func toFeature(row parquet.Row, schema *parquet.Schema, metadata *geoparquet.Metadata) (*Feature, error) {
 	properties := map[string]any{}
 	if err := schema.Reconstruct(&properties, row); err != nil {
@@ -57,63 +52,28 @@ func toFeature(row parquet.Row, schema *parquet.Schema, metadata *geoparquet.Met
 
 	var primaryGeometry orb.Geometry
 
-	for geometryName := range metadata.Columns {
-		geometryInterface, ok := properties[geometryName]
+	for name := range metadata.Columns {
+		value, ok := properties[name]
 		if !ok {
-			return nil, fmt.Errorf("missing geometry column: %s", geometryName)
+			return nil, fmt.Errorf("missing geometry column: %s", name)
 		}
 
-		if geometryName == metadata.PrimaryColumn {
-			delete(properties, geometryName)
+		if name == metadata.PrimaryColumn {
+			delete(properties, name)
 		}
-		if geometryInterface == nil {
+		if value == nil {
 			continue
 		}
 
-		geometryString, ok := geometryInterface.(string)
-		if !ok {
-			return nil, fmt.Errorf("unexpected geometry type: %t", geometryInterface)
-		}
-
-		encoding := metadata.Columns[geometryName].Encoding
-		if encoding == "" {
-			column, ok := schema.Lookup(geometryName)
-			if !ok {
-				return nil, fmt.Errorf("missing column: %s", geometryName)
-			}
-			nodeType := column.Node.Type()
-			if nodeType == stringType {
-				encoding = geoparquet.EncodingWKT
-			} else if nodeType == parquet.ByteArrayType {
-				encoding = geoparquet.EncodingWKB
-			} else {
-				return nil, fmt.Errorf("unsupported geometry type: %s", nodeType)
-			}
-		}
-
-		var geometry orb.Geometry
-
-		switch strings.ToUpper(encoding) {
-		case geoparquet.EncodingWKB:
-			g, err := wkb.Unmarshal([]byte(geometryString))
-			if err != nil {
-				return nil, fmt.Errorf("trouble reading geometry: %w", err)
-			}
-			geometry = g
-		case geoparquet.EncodingWKT:
-			g, err := wkt.Unmarshal(geometryString)
-			if err != nil {
-				return nil, fmt.Errorf("trouble reading geometry: %w", err)
-			}
-			geometry = g
-		default:
-			return nil, fmt.Errorf("unsupported encoding: %s", encoding)
+		geometry, err := geoparquet.Geometry(value, name, metadata, schema)
+		if err != nil {
+			return nil, err
 		}
 
-		if geometryName == metadata.PrimaryColumn {
+		if name == metadata.PrimaryColumn {
 			primaryGeometry = geometry
 		} else {
-			properties[geometryName] = geometry
+			properties[name] = geometry
 		}
 	}
 
@@ -554,6 +514,7 @@ type ConvertOptions struct {
 	MinFeatures int
 	MaxFeatures int
 	Compression string
+	Metadata    string
 }
 
 var defaultOptions = &ConvertOptions{
@@ -629,6 +590,8 @@ func ToParquet(input io.Reader, output io.Writer, convertOptions *ConvertOptions
 	var bounds *orb.Bound
 	geometryTypeLookup := map[string]bool{}
 
+	metadataString := convertOptions.Metadata
+
 	for {
 		feature, err := reader.Next()
 		if err == io.EOF {
@@ -638,7 +601,7 @@ func ToParquet(input io.Reader, output io.Writer, convertOptions *ConvertOptions
 			return err
 		}
 
-		if feature.Geometry != nil {
+		if metadataString == "" && feature.Geometry != nil {
 			b := feature.Geometry.Bound()
 			if bounds == nil {
 				bounds = &b
@@ -648,6 +611,7 @@ func ToParquet(input io.Reader, output io.Writer, convertOptions *ConvertOptions
 			}
 			geometryTypeLookup[feature.Geometry.GeoJSONType()] = true
 		}
+
 		row, err := converter.Convert(feature)
 		if err != nil {
 			return err
@@ -658,26 +622,29 @@ func ToParquet(input io.Reader, output io.Writer, convertOptions *ConvertOptions
 		}
 	}
 
-	metadata := GetDefaultMetadata()
-	if bounds != nil {
-		metadata.Columns[metadata.PrimaryColumn].Bounds = []float64{
-			bounds.Left(), bounds.Bottom(), bounds.Right(), bounds.Top(),
+	if metadataString == "" {
+		metadata := GetDefaultMetadata()
+		if bounds != nil {
+			metadata.Columns[metadata.PrimaryColumn].Bounds = []float64{
+				bounds.Left(), bounds.Bottom(), bounds.Right(), bounds.Top(),
+			}
 		}
-	}
 
-	geometryTypes := []string{}
-	if len(geometryTypeLookup) > 0 {
-		for geometryType := range geometryTypeLookup {
-			geometryTypes = append(geometryTypes, geometryType)
+		geometryTypes := []string{}
+		if len(geometryTypeLookup) > 0 {
+			for geometryType := range geometryTypeLookup {
+				geometryTypes = append(geometryTypes, geometryType)
+			}
 		}
-	}
-	metadata.Columns[metadata.PrimaryColumn].GeometryTypes = geometryTypes
+		metadata.Columns[metadata.PrimaryColumn].GeometryTypes = geometryTypes
 
-	metadataEncoded, jsonErr := json.Marshal(metadata)
-	if jsonErr != nil {
-		return fmt.Errorf("failed to serialize geo metadata: %w", jsonErr)
+		metadataBytes, jsonErr := json.Marshal(metadata)
+		if jsonErr != nil {
+			return fmt.Errorf("failed to serialize geo metadata: %w", jsonErr)
+		}
+		metadataString = string(metadataBytes)
 	}
 
-	writer.SetKeyValueMetadata(geoparquet.MetadataKey, string(metadataEncoded))
+	writer.SetKeyValueMetadata(geoparquet.MetadataKey, metadataString)
 	return writer.Close()
 }
diff --git a/internal/geoparquet/geoparquet.go b/internal/geoparquet/geoparquet.go
index aee8317..66d04e1 100644
--- a/internal/geoparquet/geoparquet.go
+++ b/internal/geoparquet/geoparquet.go
@@ -18,18 +18,42 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"strings"
 
+	"github.com/paulmach/orb"
+	"github.com/paulmach/orb/encoding/wkb"
+	"github.com/paulmach/orb/encoding/wkt"
 	"github.com/segmentio/parquet-go"
 )
 
 const (
-	Version               = "1.0.0-beta.1"
-	MetadataKey           = "geo"
-	EncodingWKB           = "WKB"
-	EncodingWKT           = "WKT"
-	defaultGeometryColumn = "geometry"
+	Version                     = "1.0.0-beta.1"
+	MetadataKey                 = "geo"
+	EncodingWKB                 = "WKB"
+	EncodingWKT                 = "WKT"
+	EdgesPlanar                 = "planar"
+	EdgesSpherical              = "spherical"
+	OrientationCounterClockwise = "counterclockwise"
+	defaultGeometryColumn       = "geometry"
 )
 
+var GeometryTypes = []string{
+	"Point",
+	"LineString",
+	"Polygon",
+	"MultiPoint",
+	"MultiLineString",
+	"MultiPolygon",
+	"GeometryCollection",
+	"Point Z",
+	"LineString Z",
+	"Polygon Z",
+	"MultiPoint Z",
+	"MultiLineString Z",
+	"MultiPolygon Z",
+	"GeometryCollection Z",
+}
+
 type Metadata struct {
 	Version       string                     `json:"version"`
 	PrimaryColumn string                     `json:"primary_column"`
@@ -44,7 +68,7 @@ type GeometryColumn struct {
 	Edges         string    `json:"edges,omitempty"`
 	Orientation   string    `json:"orientation,omitempty"`
 	Bounds        []float64 `json:"bbox,omitempty"`
-	Epoch         int       `json:"epoch,omitempty"`
+	Epoch         float64   `json:"epoch,omitempty"`
 }
 
 func (col *GeometryColumn) GetGeometryTypes() []string {
@@ -190,3 +214,75 @@ func (r *RowReader) Next() (parquet.Row, error) {
 func (r *RowReader) Close() error {
 	return r.closeReader()
 }
+
+type GenericWriter[T any] struct {
+	writer   *parquet.GenericWriter[T]
+	metadata *Metadata
+}
+
+func NewGenericWriter[T any](output io.Writer, metadata *Metadata, options ...parquet.WriterOption) *GenericWriter[T] {
+	return &GenericWriter[T]{
+		writer:   parquet.NewGenericWriter[T](output, options...),
+		metadata: metadata,
+	}
+}
+
+func (w *GenericWriter[T]) Write(rows []T) (int, error) {
+	return w.writer.Write(rows)
+}
+
+func (w *GenericWriter[T]) Close() error {
+	jsonMetadata, jsonErr := json.Marshal(w.metadata)
+	if jsonErr != nil {
+		return fmt.Errorf("trouble encoding metadata as json: %w", jsonErr)
+	}
+
+	w.writer.SetKeyValueMetadata(MetadataKey, string(jsonMetadata))
+	return w.writer.Close()
+}
+
+var stringType = parquet.String().Type()
+
+func Geometry(value any, name string, metadata *Metadata, schema *parquet.Schema) (orb.Geometry, error) {
+	geometryString, ok := value.(string)
+	if !ok {
+		return nil, fmt.Errorf("unexpected geometry type: %t", value)
+	}
+
+	encoding := metadata.Columns[name].Encoding
+	if encoding == "" {
+		column, ok := schema.Lookup(name)
+		if !ok {
+			return nil, fmt.Errorf("missing column: %s", name)
+		}
+		nodeType := column.Node.Type()
+		if nodeType == stringType {
+			encoding = EncodingWKT
+		} else if nodeType == parquet.ByteArrayType {
+			encoding = EncodingWKB
+		} else {
+			return nil, fmt.Errorf("unsupported geometry type: %s", nodeType)
+		}
+	}
+
+	var geometry orb.Geometry
+
+	switch strings.ToUpper(encoding) {
+	case EncodingWKB:
+		g, err := wkb.Unmarshal([]byte(geometryString))
+		if err != nil {
+			return nil, fmt.Errorf("trouble reading geometry: %w", err)
+		}
+		geometry = g
+	case EncodingWKT:
+		g, err := wkt.Unmarshal(geometryString)
+		if err != nil {
+			return nil, fmt.Errorf("trouble reading geometry: %w", err)
+		}
+		geometry = g
+	default:
+		return nil, fmt.Errorf("unsupported encoding: %s", encoding)
+	}
+
+	return geometry, nil
+}
diff --git a/internal/validator/rules.go b/internal/validator/rules.go
new file mode 100644
index 0000000..257ec9f
--- /dev/null
+++ b/internal/validator/rules.go
@@ -0,0 +1,659 @@
+// Copyright 2023 Planet Labs PBC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validator
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+
+	"github.com/paulmach/orb"
+	"github.com/planetlabs/gpq/internal/geoparquet"
+	"github.com/santhosh-tekuri/jsonschema/v5"
+	"github.com/segmentio/parquet-go"
+)
+
+type MetadataMap map[string]any
+
+type ColumnMetdataMap map[string]map[string]any
+
+type FileInfo struct {
+	File     *parquet.File
+	Metadata *geoparquet.Metadata
+}
+
+type RuleData interface {
+	*parquet.File | MetadataMap | ColumnMetdataMap | *FileInfo
+}
+
+type EncodedGeometryMap map[string]any
+type DecodedGeometryMap map[string]orb.Geometry
+
+type RowData interface {
+	EncodedGeometryMap | DecodedGeometryMap
+}
+
+type Rule interface {
+	Title() string
+	Validate() error
+}
+
+type errFatal string
+
+var ErrFatal = errFatal("fatal error")
+
+func (e errFatal) Error() string {
+	return string(e)
+}
+
+func (e errFatal) Is(target error) bool {
+	_, ok := target.(errFatal)
+	return ok
+}
+
+func fatal(format string, a ...any) errFatal {
+	return errFatal(fmt.Sprintf(format, a...))
+}
+
+type GenericRule[T RuleData] struct {
+	title    string
+	value    T
+	validate func(T) error
+}
+
+var _ Rule = (*GenericRule[*parquet.File])(nil)
+
+func (r *GenericRule[T]) Title() string {
+	return r.title
+}
+
+func (r *GenericRule[T]) Init(value T) {
+	r.value = value
+}
+
+func (r *GenericRule[T]) Validate() error {
+	return r.validate(r.value)
+}
+
+type RowRule[T RowData] struct {
+	title string
+	row   func(*FileInfo, T) error
+	info  *FileInfo
+	err   error
+}
+
+var _ Rule = (*RowRule[EncodedGeometryMap])(nil)
+
+func (r *RowRule[T]) Title() string {
+	return r.title
+}
+
+func (r *RowRule[T]) Init(info *FileInfo) {
+	r.info = info
+}
+
+func (r *RowRule[T]) Row(data T) error {
+	if r.err == nil {
+		r.err = r.row(r.info, data)
+	}
+	return r.err
+}
+
+func (r *RowRule[T]) Validate() error {
+	return r.err
+}
+
+func asJSON(value any) string {
+	data, err := json.Marshal(value)
+	if err != nil {
+		return fmt.Sprintf("<unable to encode as JSON: %s>", err)
+	}
+	return string(data)
+}
+
+func RequiredGeoKey() Rule {
+	return &GenericRule[*parquet.File]{
+		title: fmt.Sprintf("file must include a %q metadata key", geoparquet.MetadataKey),
+		validate: func(file *parquet.File) error {
+			_, ok := file.Lookup(geoparquet.MetadataKey)
+			if !ok {
+				return fatal("missing %q metadata key", geoparquet.MetadataKey)
+			}
+			return nil
+		},
+	}
+}
+
+func RequiredMetadataType() Rule {
+	return &GenericRule[*parquet.File]{
+		title: "metadata must be a JSON object",
+		validate: func(file *parquet.File) error {
+			value, geoErr := geoparquet.GetMetadataValue(file)
+			if geoErr != nil {
+				return fatal(geoErr.Error())
+			}
+
+			metadataMap := map[string]any{}
+			jsonErr := json.Unmarshal([]byte(value), &metadataMap)
+			if jsonErr != nil {
+				return fatal("failed to parse file metadata as a JSON object")
+			}
+			return nil
+		},
+	}
+}
+
+func RequiredVersion() Rule {
+	return &GenericRule[MetadataMap]{
+		title: `metadata must include a "version" string`,
+		validate: func(metadata MetadataMap) error {
+			value, ok := metadata["version"]
+			if !ok {
+				return errors.New(`missing "version" in metadata`)
+			}
+			version, ok := value.(string)
+			if !ok {
+				return fmt.Errorf(`expected "version" to be a string, got %s`, asJSON(value))
+			}
+			if version == "" {
+				return errors.New(`expected "version" to be a non-empty string`)
+			}
+			return nil
+		},
+	}
+}
+
+func RequiredPrimaryColumn() Rule {
+	return &GenericRule[MetadataMap]{
+		title: `metadata must include a "primary_column" string`,
+		validate: func(metadata MetadataMap) error {
+			name, ok := metadata["primary_column"]
+			if !ok {
+				return errors.New(`missing "primary_column" in metadata`)
+			}
+			_, ok = name.(string)
+			if !ok {
+				return fmt.Errorf(`expected "primary_column" to be a string, got %s`, asJSON(name))
+			}
+			return nil
+		},
+	}
+}
+
+func RequiredColumns() Rule {
+	return &GenericRule[MetadataMap]{
+		title: `metadata must include a "columns" object`,
+		validate: func(metadata MetadataMap) error {
+			columnsAny, ok := metadata["columns"]
+			if !ok {
+				return fatal(`missing "columns" in metadata`)
+			}
+			columnsMap, ok := columnsAny.(map[string]any)
+			if !ok {
+				return fatal(`expected "columns" to be an object, got %s`, asJSON(columnsAny))
+			}
+			for name, meta := range columnsMap {
+				_, ok := meta.(map[string]any)
+				if !ok {
+					return fatal(`expected column %q to be an object, got %s`, name, asJSON(meta))
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func RequiredColumnEncoding() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `column metadata must include a valid "encoding" string`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				_, ok := meta["encoding"]
+				if !ok {
+					return fmt.Errorf(`missing "encoding" for column %q`, name)
+				}
+				encoding, ok := meta["encoding"].(string)
+				if !ok {
+					return fmt.Errorf(`expected "encoding" for column %q to be a string, got %s`, name, asJSON(meta["encoding"]))
+				}
+				if encoding != geoparquet.EncodingWKB {
+					return fmt.Errorf(`unsupported encoding %q for column %q`, encoding, name)
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func isValidGeometryType(geometryType string) bool {
+	for _, validGeometryType := range geoparquet.GeometryTypes {
+		if geometryType == validGeometryType {
+			return true
+		}
+	}
+	return false
+}
+
+func RequiredGeometryTypes() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `column metadata must include a "geometry_types" list`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				_, ok := meta["geometry_types"]
+				if !ok {
+					return fmt.Errorf(`missing "geometry_types" for column %q`, name)
+				}
+				geometryTypes, ok := meta["geometry_types"].([]any)
+				if !ok {
+					return fmt.Errorf(`expected "geometry_types" for column %q to be a list, got %s`, name, asJSON(meta["geometry_types"]))
+				}
+				for _, value := range geometryTypes {
+					geometryType, ok := value.(string)
+					if !ok {
+						return fmt.Errorf(`expected "geometry_types" for column %q to be a list of strings, got %s`, name, asJSON(geometryTypes))
+					}
+					if !isValidGeometryType(geometryType) {
+						return fmt.Errorf(`unsupported geometry type %q for column %q`, geometryType, name)
+					}
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func projJSONSchemaUrl(version string) string {
+	return fmt.Sprintf("https://proj.org/schemas/v%s/projjson.schema.json", version)
+}
+
+func simplifiedValidationMessage(err *jsonschema.ValidationError) string {
+	leaf := err
+	for len(leaf.Causes) > 0 {
+		leaf = leaf.Causes[0]
+	}
+	location := leaf.InstanceLocation
+	if location == "" {
+		location = "input"
+	}
+	return fmt.Sprintf("%s is invalid: %s", location, leaf.Message)
+}
+
+func OptionalCRS() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `optional "crs" must be null or a PROJJSON object`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				if meta["crs"] == nil {
+					return nil
+				}
+				crs, ok := meta["crs"].(map[string]any)
+				if !ok {
+					return fmt.Errorf(`expected "crs" for column %q to be an object, got %s`, name, asJSON(meta["crs"]))
+				}
+				schemaUrl, ok := crs["$schema"].(string)
+				if !ok {
+					schemaUrl = projJSONSchemaUrl("0.6")
+				}
+				compiler := jsonschema.NewCompiler()
+				schema, schemaErr := compiler.Compile(schemaUrl)
+				if schemaErr != nil {
+					return fmt.Errorf("failed to compile PROJJSON schema: %w", schemaErr)
+				}
+				err := schema.Validate(crs)
+				if err == nil {
+					continue
+				}
+				validationErr, ok := err.(*jsonschema.ValidationError)
+				if !ok {
+					return err
+				}
+				return fmt.Errorf("validation failed against %s: %s", schemaUrl, simplifiedValidationMessage(validationErr))
+			}
+			return nil
+		},
+	}
+}
+
+func OptionalOrientation() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `optional "orientation" must be a valid string`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				_, ok := meta["orientation"]
+				if !ok {
+					return nil
+				}
+				orientation, ok := meta["orientation"].(string)
+				if !ok {
+					return fmt.Errorf(`expected "orientation" for column %q to be a string, got %s`, name, asJSON(meta["orientation"]))
+				}
+				if orientation != geoparquet.OrientationCounterClockwise {
+					return fmt.Errorf(`unsupported orientation %q for column %q, expected %q`, orientation, name, geoparquet.OrientationCounterClockwise)
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func OptionalEdges() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `optional "edges" must be a valid string`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				_, ok := meta["edges"]
+				if !ok {
+					return nil
+				}
+				edges, ok := meta["edges"].(string)
+				if !ok {
+					return fmt.Errorf(`expected "edges" for column %q to be a string, got %s`, name, asJSON(meta["edges"]))
+				}
+				if edges != geoparquet.EdgesPlanar && edges != geoparquet.EdgesSpherical {
+					return fmt.Errorf(`unsupported edges %q for column %q, expected %q or %q`, edges, name, geoparquet.EdgesPlanar, geoparquet.EdgesSpherical)
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func OptionalBbox() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `optional "bbox" must be an array of 4 or 6 numbers`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				_, ok := meta["bbox"]
+				if !ok {
+					return nil
+				}
+				bbox, ok := meta["bbox"].([]any)
+				if !ok {
+					return fmt.Errorf(`expected "bbox" for column %q to be a list, got %s`, name, asJSON(meta["bbox"]))
+				}
+				if len(bbox) != 4 && len(bbox) != 6 {
+					return fmt.Errorf(`expected "bbox" for column %q to be a list of 4 or 6 numbers, got %s`, name, asJSON(bbox))
+				}
+				for _, value := range bbox {
+					_, ok := value.(float64)
+					if !ok {
+						return fatal(`expected "bbox" for column %q to be a list of numbers, got %s`, name, asJSON(bbox))
+					}
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func OptionalEpoch() Rule {
+	return &GenericRule[ColumnMetdataMap]{
+		title: `optional "epoch" must be a number`,
+		validate: func(columnMetadata ColumnMetdataMap) error {
+			for name, meta := range columnMetadata {
+				_, ok := meta["epoch"]
+				if !ok {
+					return nil
+				}
+				_, ok = meta["epoch"].(float64)
+				if !ok {
+					return fatal(`expected "epoch" for column %q to be a number, got %s`, name, asJSON(meta["epoch"]))
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func PrimaryColumnInLookup() Rule {
+	return &GenericRule[*FileInfo]{
+		title: `column metadata must include the "primary_column" name`,
+		validate: func(info *FileInfo) error {
+			name := info.Metadata.PrimaryColumn
+			_, ok := info.Metadata.Columns[name]
+			if !ok {
+				return fmt.Errorf("the %q column is not included in the column metadata", name)
+			}
+			return nil
+		},
+	}
+}
+
+func GeometryDataType() Rule {
+	return &GenericRule[*FileInfo]{
+		title: "geometry columns must be stored using the BYTE_ARRAY parquet type",
+		validate: func(info *FileInfo) error {
+			metadata := info.Metadata
+			schema := info.File.Schema()
+			for name := range metadata.Columns {
+				column, ok := schema.Lookup(name)
+				if !ok {
+					return fatal("missing geometry column %q", name)
+				}
+				if column.Node.Type() != parquet.ByteArrayType {
+					return fatal("unexpected type for column %q, got %s", name, column.Node.Type())
+				}
+			}
+
+			return nil
+		},
+	}
+}
+
+func GeometryUngrouped() Rule {
+	return &GenericRule[*FileInfo]{
+		title: "geometry columns must not be grouped",
+		validate: func(info *FileInfo) error {
+			metadata := info.Metadata
+			schema := info.File.Schema()
+			for name := range metadata.Columns {
+				column, ok := schema.Lookup(name)
+				if !ok {
+					return fatal("missing geometry column %q", name)
+				}
+				if !column.Node.Leaf() {
+					return fmt.Errorf("column %q must not be a group", name)
+				}
+			}
+
+			return nil
+		},
+	}
+}
+
+func GeometryRepetition() Rule {
+	return &GenericRule[*FileInfo]{
+		title: "geometry columns must be required or optional, not repeated",
+		validate: func(info *FileInfo) error {
+			metadata := info.Metadata
+			schema := info.File.Schema()
+			for name := range metadata.Columns {
+				column, ok := schema.Lookup(name)
+				if !ok {
+					return fatal("missing geometry column %q", name)
+				}
+				if column.Node.Repeated() {
+					return fmt.Errorf("column %q must not be repeated", name)
+				}
+				if !column.Node.Required() && !column.Node.Optional() {
+					return fmt.Errorf("column %q must be required or optional", name)
+				}
+			}
+
+			return nil
+		},
+	}
+}
+
+func GeometryEncoding() Rule {
+	return &RowRule[EncodedGeometryMap]{
+		title: `all geometry values match the "encoding" metadata`,
+		row: func(info *FileInfo, geometries EncodedGeometryMap) error {
+			schema := info.File.Schema()
+			metadata := info.Metadata
+
+			for name, encoded := range geometries {
+				_, err := geoparquet.Geometry(encoded, name, metadata, schema)
+				if err != nil {
+					return fatal("invalid geometry in column %q: %s", name, err)
+				}
+			}
+
+			return nil
+		},
+	}
+}
+
+func GeometryTypes() Rule {
+	return &RowRule[DecodedGeometryMap]{
+		title: `all geometry types must be included in the "geometry_types" metadata (if not empty)`,
+		row: func(info *FileInfo, geometries DecodedGeometryMap) error {
+			metadata := info.Metadata
+
+			for name, geometry := range geometries {
+				meta, ok := metadata.Columns[name]
+				if !ok {
+					return fatal("missing metadata for column %q", name)
+				}
+				geometryTypes := meta.GetGeometryTypes()
+				if len(geometryTypes) == 0 {
+					continue
+				}
+				actualType := geometry.GeoJSONType()
+				included := false
+				for _, expectedType := range geometryTypes {
+					if actualType == expectedType || actualType+" Z" == expectedType {
+						included = true
+						break
+					}
+				}
+				if !included {
+					return fmt.Errorf("unexpected geometry type %q for column %q", actualType, name)
+				}
+			}
+
+			return nil
+		},
+	}
+}
+
+func GeometryOrientation() Rule {
+	return &RowRule[DecodedGeometryMap]{
+		title: `all polygon geometries must follow the "orientation" metadata (if present)`,
+		row: func(info *FileInfo, geometries DecodedGeometryMap) error {
+			metadata := info.Metadata
+
+			for name, geometry := range geometries {
+				meta, ok := metadata.Columns[name]
+				if !ok {
+					return fatal("missing metadata for column %q", name)
+				}
+				if meta.Orientation == "" {
+					continue
+				}
+				if meta.Orientation != geoparquet.OrientationCounterClockwise {
+					return fmt.Errorf("unsupported orientation %q for column %q", meta.Orientation, name)
+				}
+				polygon, ok := geometry.(orb.Polygon)
+				if !ok {
+					continue
+				}
+
+				expectedExterior := orb.CCW
+				expectedInterior := orb.CW
+
+				for i, ring := range polygon {
+					orientation := ring.Orientation()
+					if i == 0 {
+						if orientation != expectedExterior {
+							return fmt.Errorf("invalid orientation for exterior ring in column %q", name)
+						}
+						continue
+					}
+					if orientation != expectedInterior {
+						return fmt.Errorf("invalid orientation for interior ring in column %q", name)
+					}
+				}
+			}
+
+			return nil
+		},
+	}
+}
+
+func GeometryBounds() Rule {
+	return &RowRule[DecodedGeometryMap]{
+		title: `all geometries must fall within the "bbox" metadata (if present)`,
+		row: func(info *FileInfo, geometries DecodedGeometryMap) error {
+			metadata := info.Metadata
+
+			for name, geometry := range geometries {
+				meta, ok := metadata.Columns[name]
+				if !ok {
+					return fatal("missing metadata for column %q", name)
+				}
+				bbox := meta.Bounds
+				length := len(bbox)
+				if length == 0 {
+					continue
+				}
+				var x0 float64
+				var x1 float64
+				var y0 float64
+				var y1 float64
+				if length == 4 {
+					x0 = bbox[0]
+					y0 = bbox[1]
+					x1 = bbox[2]
+					y1 = bbox[3]
+				} else if length == 6 {
+					x0 = bbox[0]
+					y0 = bbox[1]
+					x1 = bbox[3]
+					y1 = bbox[4]
+				} else {
+					return fmt.Errorf("invalid bbox length for column %q", name)
+				}
+
+				bound := geometry.Bound()
+				if x0 <= x1 {
+					// bbox does not cross the antimeridian
+					if bound.Min.X() < x0 {
+						return fmt.Errorf("geometry in column %q extends to %f, west of the bbox", name, bound.Min.X())
+					}
+					if bound.Max.X() > x1 {
+						return fmt.Errorf("geometry in column %q extends to %f, east of the bbox", name, bound.Max.X())
+					}
+				} else {
+					// bbox crosses the antimeridian
+					if bound.Max.X() > x1 && bound.Max.X() < x0 {
+						return fmt.Errorf("geometry in column %q extends to %f, outside of the bbox", name, bound.Max.X())
+					}
+					if bound.Min.X() < x0 && bound.Min.X() > x1 {
+						return fmt.Errorf("geometry in column %q extends to %f, outside of the bbox", name, bound.Min.X())
+					}
+				}
+				if bound.Min.Y() < y0 {
+					return fmt.Errorf("geometry in column %q extends to %f, south of the bbox", name, bound.Min.Y())
+				}
+				if bound.Max.Y() > y1 {
+					return fmt.Errorf("geometry in column %q extends to %f, north of the bbox", name, bound.Max.Y())
+				}
+			}
+
+			return nil
+		},
+	}
+}
diff --git a/internal/validator/validator.go b/internal/validator/validator.go
index 336d4e0..5573cbc 100644
--- a/internal/validator/validator.go
+++ b/internal/validator/validator.go
@@ -19,91 +19,285 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 
 	"github.com/planetlabs/gpq/internal/geoparquet"
-	"github.com/santhosh-tekuri/jsonschema/v5"
 	_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
 	"github.com/segmentio/parquet-go"
 )
 
 type Validator struct {
-	compiler  *jsonschema.Compiler
-	schemaMap map[string]string
+	rules        []Rule
+	metadataOnly bool
 }
 
-// Options for the Validator.
-type Options struct {
-	// A lookup of substitute schema locations.  The key is the original schema location
-	// and the value is the substitute location.
-	SchemaMap map[string]string
-}
-
-func (v *Validator) apply(options *Options) {
-	if options.SchemaMap != nil {
-		v.schemaMap = options.SchemaMap
+func MetadataOnlyRules() []Rule {
+	return []Rule{
+		RequiredGeoKey(),
+		RequiredMetadataType(),
+		RequiredVersion(),
+		RequiredPrimaryColumn(),
+		RequiredColumns(),
+		PrimaryColumnInLookup(),
+		RequiredColumnEncoding(),
+		RequiredGeometryTypes(),
+		OptionalCRS(),
+		OptionalOrientation(),
+		OptionalEdges(),
+		OptionalBbox(),
+		OptionalEpoch(),
+		GeometryDataType(),
+		GeometryUngrouped(),
+		GeometryRepetition(),
 	}
 }
 
-func schemaUrl(version string) string {
-	return fmt.Sprintf("https://geoparquet.org/releases/v%s/schema.json", version)
+func DataScanningRules() []Rule {
+	return []Rule{
+		GeometryEncoding(),
+		GeometryTypes(),
+		GeometryOrientation(),
+		GeometryBounds(),
+	}
 }
 
 // New creates a new Validator.
-func New(options ...*Options) *Validator {
-	v := &Validator{
-		compiler: jsonschema.NewCompiler(),
+func New(metadataOnly bool) *Validator {
+	rules := MetadataOnlyRules()
+	if !metadataOnly {
+		rules = append(rules, DataScanningRules()...)
 	}
-	for _, opt := range options {
-		v.apply(opt)
+
+	v := &Validator{
+		rules:        rules,
+		metadataOnly: metadataOnly,
 	}
 
 	return v
 }
 
-// Validate validates a GeoParquet file.
-func (v *Validator) Validate(ctx context.Context, resource string) error {
+type Report struct {
+	Checks       []*Check `json:"checks"`
+	MetadataOnly bool     `json:"metadataOnly"`
+}
+
+type Check struct {
+	Title   string `json:"title"`
+	Run     bool   `json:"run"`
+	Passed  bool   `json:"passed"`
+	Message string `json:"message,omitempty"`
+}
 
+// Validate opens and validates a GeoParquet file.
+func (v *Validator) Validate(ctx context.Context, resource string) (*Report, error) {
 	stat, statError := os.Stat(resource)
 	if statError != nil {
-		return fmt.Errorf("failed to get size of %q: %w", resource, statError)
+		return nil, fmt.Errorf("failed to get size of %q: %w", resource, statError)
 	}
 
 	input, readErr := os.Open(resource)
 	if readErr != nil {
-		return fmt.Errorf("failed to read from %q: %w", resource, readErr)
+		return nil, fmt.Errorf("failed to read from %q: %w", resource, readErr)
 	}
 	defer input.Close()
 
 	file, fileErr := parquet.OpenFile(input, stat.Size())
 	if fileErr != nil {
-		return fileErr
+		return nil, fileErr
 	}
 
-	value, geoErr := geoparquet.GetMetadataValue(file)
-	if geoErr != nil {
-		return geoErr
+	return v.Report(ctx, file)
+}
+
+// Report generates a validation report for a GeoParquet file.
+func (v *Validator) Report(ctx context.Context, file *parquet.File) (*Report, error) {
+	checks := make([]*Check, len(v.rules))
+	for i, rule := range v.rules {
+		checks[i] = &Check{
+			Title: rule.Title(),
+		}
 	}
 
-	geoFileMetadata := map[string]any{}
-	jsonErr := json.Unmarshal([]byte(value), &geoFileMetadata)
-	if jsonErr != nil {
-		return fmt.Errorf("failed to parse geo metadata: %w", jsonErr)
+	report := &Report{Checks: checks, MetadataOnly: v.metadataOnly}
+
+	// run all file rules
+	if err := run(v, checks, file); err != nil {
+		return report, nil
 	}
 
-	versionData, ok := geoFileMetadata["version"]
-	if !ok {
-		return errors.New("missing version in geo metadata")
+	// run all metadata rules
+	metadataValue, metadataErr := geoparquet.GetMetadataValue(file)
+	if metadataErr != nil {
+		return nil, metadataErr
+	}
+
+	metadataMap := MetadataMap{}
+	if err := json.Unmarshal([]byte(metadataValue), &metadataMap); err != nil {
+		return nil, fmt.Errorf("failed to decode metadata: %w", err)
 	}
-	version, ok := versionData.(string)
+
+	if err := run(v, checks, metadataMap); err != nil {
+		return report, nil
+	}
+
+	// run all column metadata rules
+	columnMetadataMap := ColumnMetdataMap{}
+	columnMetadataAny, ok := metadataMap["columns"].(map[string]any)
 	if !ok {
-		return fmt.Errorf("expected version to be a string, got %v", versionData)
+		return nil, errors.New("columns metadata is not an object")
 	}
 
-	schema, schemaErr := v.compiler.Compile(schemaUrl(version))
-	if schemaErr != nil {
-		return fmt.Errorf("failed to compile schema: %w", schemaErr)
+	for k, v := range columnMetadataAny {
+		col, ok := v.(map[string]any)
+		if !ok {
+			return nil, errors.New("column metadata is not an object")
+		}
+		columnMetadataMap[k] = col
 	}
 
-	return schema.Validate(geoFileMetadata)
+	if err := run(v, checks, columnMetadataMap); err != nil {
+		return report, nil
+	}
+
+	// run all rules that need the file and parsed metadata
+	metadata, err := geoparquet.GetMetadata(file)
+	if err != nil {
+		return nil, err
+	}
+
+	info := &FileInfo{Metadata: metadata, File: file}
+	if err := run(v, checks, info); err != nil {
+		return report, nil
+	}
+
+	if v.metadataOnly {
+		return report, nil
+	}
+
+	// run all the data scanning rules
+	rowReader := geoparquet.NewRowReader(file)
+
+	encodedGeometryRules := []*RowRule[EncodedGeometryMap]{}
+	encodedGeometryChecks := []*Check{}
+	for i, r := range v.rules {
+		rule, ok := r.(*RowRule[EncodedGeometryMap])
+		if ok {
+			rule.Init(info)
+			encodedGeometryRules = append(encodedGeometryRules, rule)
+			encodedGeometryChecks = append(encodedGeometryChecks, checks[i])
+		}
+	}
+
+	decodedGeometryRules := []*RowRule[DecodedGeometryMap]{}
+	decodedGeometryChecks := []*Check{}
+	for i, r := range v.rules {
+		rule, ok := r.(*RowRule[DecodedGeometryMap])
+		if ok {
+			rule.Init(info)
+			decodedGeometryRules = append(decodedGeometryRules, rule)
+			decodedGeometryChecks = append(decodedGeometryChecks, checks[i])
+		}
+	}
+
+	schema := file.Schema()
+	for {
+		row, readErr := rowReader.Next()
+		if readErr == io.EOF {
+			break
+		}
+		if readErr != nil {
+			return nil, fmt.Errorf("failed to read row: %w", readErr)
+		}
+
+		properties := map[string]any{}
+		if err := schema.Reconstruct(&properties, row); err != nil {
+			return nil, fmt.Errorf("failed to reconstruct row: %w", err)
+		}
+
+		encodedGeometryMap := EncodedGeometryMap{}
+		for name := range metadata.Columns {
+			value, ok := properties[name]
+			if !ok {
+				return nil, fmt.Errorf("missing column %q", name)
+			}
+			encodedGeometryMap[name] = value
+		}
+
+		for i, rule := range encodedGeometryRules {
+			check := encodedGeometryChecks[i]
+			if err := rule.Row(encodedGeometryMap); errors.Is(err, ErrFatal) {
+				check.Message = err.Error()
+				check.Run = true
+				return report, nil
+			}
+		}
+
+		decodedGeometryMap := DecodedGeometryMap{}
+		for name, value := range encodedGeometryMap {
+			decoded, err := geoparquet.Geometry(value, name, metadata, schema)
+			if err != nil {
+				return nil, fmt.Errorf("failed to decode geometry: %w", err)
+			}
+			decodedGeometryMap[name] = decoded
+		}
+
+		for i, rule := range decodedGeometryRules {
+			check := decodedGeometryChecks[i]
+			if err := rule.Row(decodedGeometryMap); errors.Is(err, ErrFatal) {
+				check.Message = err.Error()
+				check.Run = true
+				return report, nil
+			}
+		}
+	}
+
+	for i, rule := range encodedGeometryRules {
+		check := encodedGeometryChecks[i]
+		check.Run = true
+		if err := rule.Validate(); err != nil {
+			check.Message = err.Error()
+			if errors.Is(err, ErrFatal) {
+				return report, nil
+			}
+			continue
+		}
+		check.Passed = true
+	}
+
+	for i, rule := range decodedGeometryRules {
+		check := decodedGeometryChecks[i]
+		check.Run = true
+		if err := rule.Validate(); err != nil {
+			check.Message = err.Error()
+			if errors.Is(err, ErrFatal) {
+				return report, nil
+			}
+			continue
+		}
+		check.Passed = true
+	}
+
+	return report, nil
+}
+
+func run[T RuleData](v *Validator, checks []*Check, data T) error {
+	for i, r := range v.rules {
+		check := checks[i]
+		rule, ok := r.(*GenericRule[T])
+		if !ok {
+			continue
+		}
+		rule.Init(data)
+		check.Run = true
+		if err := rule.Validate(); err != nil {
+			check.Message = err.Error()
+			if errors.Is(err, ErrFatal) {
+				return err
+			}
+			continue
+		}
+		check.Passed = true
+	}
+	return nil
 }
diff --git a/readme.md b/readme.md
index 16621d6..6b3d8c6 100644
--- a/readme.md
+++ b/readme.md
@@ -67,6 +67,5 @@ gpq describe example.parquet
 
  * Non-geographic CRS information is not preserved when converting GeoParquet to GeoJSON.
  * Page and row group size is not configurable when writing GeoParquet.  This may change soon.
- * GeoParquet files are written using ZSTD compression.  This is not configurable but may change soon.
  * Reading GeoParquet files with multiple geometry columns is supported.  Reading GeoJSON files with multiple geometry properties is not supported.
  * Feature identifiers in GeoJSON are not written to GeoParquet columns.  This may change soon.


Test patch:
diff --git a/internal/geojson/geojson_test.go b/internal/geojson/geojson_test.go
index 5a7f90b..2ce0503 100644
--- a/internal/geojson/geojson_test.go
+++ b/internal/geojson/geojson_test.go
@@ -16,7 +16,6 @@ package geojson_test
 
 import (
 	"bytes"
-	"encoding/json"
 	"fmt"
 	"io"
 	"os"
@@ -561,20 +560,13 @@ func TestRoundTripSparseProperties(t *testing.T) {
 
 func makeGeoParquet[T any](rows []T, metadata *geoparquet.Metadata) (*parquet.File, error) {
 	data := &bytes.Buffer{}
-
-	writer := parquet.NewGenericWriter[T](data)
+	writer := geoparquet.NewGenericWriter[T](data, metadata)
 
 	_, writeErr := writer.Write(rows)
 	if writeErr != nil {
 		return nil, fmt.Errorf("trouble writing rows: %w", writeErr)
 	}
 
-	jsonMetadata, jsonErr := json.Marshal(metadata)
-	if jsonErr != nil {
-		return nil, fmt.Errorf("trouble encoding metadata as json: %w", jsonErr)
-	}
-
-	writer.SetKeyValueMetadata(geoparquet.MetadataKey, string(jsonMetadata))
 	closeErr := writer.Close()
 	if closeErr != nil {
 		return nil, fmt.Errorf("trouble closing writer: %w", closeErr)
@@ -584,12 +576,12 @@ func makeGeoParquet[T any](rows []T, metadata *geoparquet.Metadata) (*parquet.Fi
 }
 
 func TestWKT(t *testing.T) {
-	type RowType struct {
+	type Row struct {
 		Name     string `parquet:"name"`
 		Geometry string `parquet:"geometry"`
 	}
 
-	rows := []*RowType{
+	rows := []*Row{
 		{
 			Name:     "test-point",
 			Geometry: "POINT (1 2)",
@@ -640,12 +632,12 @@ func TestWKT(t *testing.T) {
 }
 
 func TestWKTNoEncoding(t *testing.T) {
-	type RowType struct {
+	type Row struct {
 		Name     string `parquet:"name"`
 		Geometry string `parquet:"geometry"`
 	}
 
-	rows := []*RowType{
+	rows := []*Row{
 		{
 			Name:     "test-point",
 			Geometry: "POINT (1 2)",
@@ -682,7 +674,7 @@ func TestWKTNoEncoding(t *testing.T) {
 }
 
 func TestWKB(t *testing.T) {
-	type RowType struct {
+	type Row struct {
 		Name     string `parquet:"name"`
 		Geometry []byte `parquet:"geometry"`
 	}
@@ -690,7 +682,7 @@ func TestWKB(t *testing.T) {
 	point, pointErr := wkb.Marshal(orb.Point{1, 2})
 	require.NoError(t, pointErr)
 
-	rows := []*RowType{
+	rows := []*Row{
 		{
 			Name:     "test-point",
 			Geometry: point,
@@ -726,7 +718,7 @@ func TestWKB(t *testing.T) {
 }
 
 func TestWKBNoEncoding(t *testing.T) {
-	type RowType struct {
+	type Row struct {
 		Name     string `parquet:"name"`
 		Geometry []byte `parquet:"geometry"`
 	}
@@ -734,7 +726,7 @@ func TestWKBNoEncoding(t *testing.T) {
 	point, pointErr := wkb.Marshal(orb.Point{1, 2})
 	require.NoError(t, pointErr)
 
-	rows := []*RowType{
+	rows := []*Row{
 		{
 			Name:     "test-point",
 			Geometry: point,
diff --git a/internal/testdata/schema/proj.org/schemas/v0.6/projjson.schema.json b/internal/testdata/schema/proj.org/schemas/v0.6/projjson.schema.json
new file mode 100644
index 0000000..6dceb2b
--- /dev/null
+++ b/internal/testdata/schema/proj.org/schemas/v0.6/projjson.schema.json
@@ -0,0 +1,1173 @@
+{
+  "$id": "https://proj.org/schemas/v0.6/projjson.schema.json",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "description": "Schema for PROJJSON (v0.6)",
+  "$comment": "This file exists both in data/ and in schemas/vXXX/. Keep both in sync. And if changing the value of $id, change PROJJSON_DEFAULT_VERSION accordingly in io.cpp",
+
+  "oneOf": [
+    { "$ref": "#/definitions/crs" },
+    { "$ref": "#/definitions/datum" },
+    { "$ref": "#/definitions/datum_ensemble" },
+    { "$ref": "#/definitions/ellipsoid" },
+    { "$ref": "#/definitions/prime_meridian" },
+    { "$ref": "#/definitions/single_operation" },
+    { "$ref": "#/definitions/concatenated_operation" },
+    { "$ref": "#/definitions/coordinate_metadata" }
+  ],
+
+  "definitions": {
+
+    "abridged_transformation": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["AbridgedTransformation"] },
+        "name": { "type": "string" },
+        "source_crs": {
+            "$ref": "#/definitions/crs",
+            "$comment": "Only present when the source_crs of the bound_crs does not match the source_crs of the AbridgedTransformation. No equivalent in WKT"
+        },
+        "method": { "$ref": "#/definitions/method" },
+        "parameters": {
+            "type": "array",
+            "items": { "$ref": "#/definitions/parameter_value" }
+        },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name", "method", "parameters" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "axis": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["Axis"] },
+        "name": { "type": "string" },
+        "abbreviation": { "type": "string" },
+        "direction": { "type": "string",
+                       "enum": [ "north",
+                                 "northNorthEast",
+                                 "northEast",
+                                 "eastNorthEast",
+                                 "east",
+                                 "eastSouthEast",
+                                 "southEast",
+                                 "southSouthEast",
+                                 "south",
+                                 "southSouthWest",
+                                 "southWest",
+                                 "westSouthWest",
+                                 "west",
+                                 "westNorthWest",
+                                 "northWest",
+                                 "northNorthWest",
+                                 "up",
+                                 "down",
+                                 "geocentricX",
+                                 "geocentricY",
+                                 "geocentricZ",
+                                 "columnPositive",
+                                 "columnNegative",
+                                 "rowPositive",
+                                 "rowNegative",
+                                 "displayRight",
+                                 "displayLeft",
+                                 "displayUp",
+                                 "displayDown",
+                                 "forward",
+                                 "aft",
+                                 "port",
+                                 "starboard",
+                                 "clockwise",
+                                 "counterClockwise",
+                                 "towards",
+                                 "awayFrom",
+                                 "future",
+                                 "past",
+                                 "unspecified" ] },
+        "meridian": { "$ref": "#/definitions/meridian" },
+        "unit": { "$ref": "#/definitions/unit" },
+        "minimum_value": { "type": "number" },
+        "maximum_value": { "type": "number" },
+        "range_meaning": { "type": "string", "enum": [ "exact", "wraparound"] },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name", "abbreviation", "direction" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "bbox": {
+      "type": "object",
+      "properties": {
+        "east_longitude": { "type": "number" },
+        "west_longitude": { "type": "number" },
+        "south_latitude": { "type": "number" },
+        "north_latitude": { "type": "number" }
+      },
+      "required" : [ "east_longitude", "west_longitude",
+                     "south_latitude", "north_latitude" ],
+      "additionalProperties": false
+    },
+
+    "bound_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["BoundCRS"] },
+        "name": { "type": "string" },
+        "source_crs": { "$ref": "#/definitions/crs" },
+        "target_crs": { "$ref": "#/definitions/crs" },
+        "transformation": { "$ref": "#/definitions/abridged_transformation" },
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "source_crs", "target_crs", "transformation" ],
+     "additionalProperties": false
+    },
+
+    "compound_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["CompoundCRS"] },
+        "name": { "type": "string" },
+        "components":  {
+           "type": "array",
+            "items": { "$ref": "#/definitions/crs" }
+        },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "components" ],
+      "additionalProperties": false
+    },
+
+    "concatenated_operation": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["ConcatenatedOperation"] },
+        "name": { "type": "string" },
+        "source_crs": { "$ref": "#/definitions/crs" },
+        "target_crs": { "$ref": "#/definitions/crs" },
+        "steps":  {
+           "type": "array",
+            "items": { "$ref": "#/definitions/single_operation" }
+        },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "source_crs", "target_crs", "steps" ],
+      "additionalProperties": false
+    },
+
+    "conversion": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["Conversion"] },
+        "name": { "type": "string" },
+        "method": { "$ref": "#/definitions/method" },
+        "parameters": {
+            "type": "array",
+            "items": { "$ref": "#/definitions/parameter_value" }
+        },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name", "method" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "coordinate_metadata": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["CoordinateMetadata"] },
+        "crs": { "$ref": "#/definitions/crs" },
+        "coordinateEpoch": { "type": "number" }
+      },
+      "required" : [ "crs" ],
+      "additionalProperties": false
+    },
+
+    "coordinate_system": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["CoordinateSystem"] },
+        "name": { "type": "string" },
+        "subtype": { "type": "string",
+                     "enum": ["Cartesian",
+                              "spherical",
+                              "ellipsoidal",
+                              "vertical",
+                              "ordinal",
+                              "parametric",
+                              "affine",
+                              "TemporalDateTime",
+                              "TemporalCount",
+                              "TemporalMeasure"]  },
+        "axis": {
+            "type": "array",
+            "items": { "$ref": "#/definitions/axis" }
+        },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "subtype", "axis" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "crs": {
+      "oneOf": [
+        { "$ref": "#/definitions/bound_crs" },
+        { "$ref": "#/definitions/compound_crs" },
+        { "$ref": "#/definitions/derived_engineering_crs" },
+        { "$ref": "#/definitions/derived_geodetic_crs" },
+        { "$ref": "#/definitions/derived_parametric_crs" },
+        { "$ref": "#/definitions/derived_projected_crs" },
+        { "$ref": "#/definitions/derived_temporal_crs" },
+        { "$ref": "#/definitions/derived_vertical_crs" },
+        { "$ref": "#/definitions/engineering_crs" },
+        { "$ref": "#/definitions/geodetic_crs" },
+        { "$ref": "#/definitions/parametric_crs" },
+        { "$ref": "#/definitions/projected_crs" },
+        { "$ref": "#/definitions/temporal_crs" },
+        { "$ref": "#/definitions/vertical_crs" }
+      ]
+    },
+
+    "datum": {
+      "oneOf": [
+        { "$ref": "#/definitions/geodetic_reference_frame" },
+        { "$ref": "#/definitions/vertical_reference_frame" },
+        { "$ref": "#/definitions/dynamic_geodetic_reference_frame" },
+        { "$ref": "#/definitions/dynamic_vertical_reference_frame" },
+        { "$ref": "#/definitions/temporal_datum" },
+        { "$ref": "#/definitions/parametric_datum" },
+        { "$ref": "#/definitions/engineering_datum" }
+      ]
+    },
+
+    "datum_ensemble": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["DatumEnsemble"] },
+        "name": { "type": "string" },
+        "members": {
+            "type": "array",
+            "items": {
+                "type": "object",
+                "properties": {
+                    "name": { "type": "string" },
+                    "id": { "$ref": "#/definitions/id" },
+                    "ids": { "$ref": "#/definitions/ids" }
+                },
+                "required" : [ "name" ],
+                "allOf": [
+                    { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+                ],
+                "additionalProperties": false
+            }
+        },
+        "ellipsoid": { "$ref": "#/definitions/ellipsoid" },
+        "accuracy": { "type": "string" },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name", "members", "accuracy" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "deformation_model": {
+      "description": "Association to a PointMotionOperation",
+      "type": "object",
+      "properties": {
+        "name": { "type": "string" },
+        "id": { "$ref": "#/definitions/id" }
+      },
+      "required" : [ "name" ],
+      "additionalProperties": false
+    },
+
+    "derived_engineering_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["DerivedEngineeringCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/engineering_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "derived_geodetic_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["DerivedGeodeticCRS",
+                           "DerivedGeographicCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/geodetic_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "derived_parametric_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["DerivedParametricCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/parametric_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "derived_projected_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["DerivedProjectedCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/projected_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "derived_temporal_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["DerivedTemporalCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/temporal_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "derived_vertical_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["DerivedVerticalCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/vertical_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "dynamic_geodetic_reference_frame": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["DynamicGeodeticReferenceFrame"] },
+        "name": {},
+        "anchor": {},
+        "anchor_epoch": {},
+        "ellipsoid": {},
+        "prime_meridian": {},
+        "frame_reference_epoch": { "type": "number" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "ellipsoid", "frame_reference_epoch" ],
+      "additionalProperties": false
+    },
+
+    "dynamic_vertical_reference_frame": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["DynamicVerticalReferenceFrame"] },
+        "name": {},
+        "anchor": {},
+        "anchor_epoch": {},
+        "frame_reference_epoch": { "type": "number" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "frame_reference_epoch" ],
+      "additionalProperties": false
+    },
+
+    "ellipsoid": {
+      "type": "object",
+      "oneOf":[
+        {
+          "properties": {
+            "$schema" : { "type": "string" },
+            "type": { "type": "string", "enum": ["Ellipsoid"] },
+            "name": { "type": "string" },
+            "semi_major_axis": { "$ref": "#/definitions/value_in_metre_or_value_and_unit" },
+            "semi_minor_axis": { "$ref": "#/definitions/value_in_metre_or_value_and_unit" },
+            "id": { "$ref": "#/definitions/id" },
+            "ids": { "$ref": "#/definitions/ids" }
+          },
+          "required" : [ "name", "semi_major_axis", "semi_minor_axis" ],
+          "additionalProperties": false
+        },
+        {
+          "properties": {
+            "$schema" : { "type": "string" },
+            "type": { "type": "string", "enum": ["Ellipsoid"] },
+            "name": { "type": "string" },
+            "semi_major_axis": { "$ref": "#/definitions/value_in_metre_or_value_and_unit" },
+            "inverse_flattening": { "type": "number" },
+            "id": { "$ref": "#/definitions/id" },
+           "ids": { "$ref": "#/definitions/ids" }
+          },
+          "required" : [ "name", "semi_major_axis", "inverse_flattening" ],
+          "additionalProperties": false
+        },
+        {
+          "properties": {
+            "$schema" : { "type": "string" },
+            "type": { "type": "string", "enum": ["Ellipsoid"] },
+            "name": { "type": "string" },
+            "radius": { "$ref": "#/definitions/value_in_metre_or_value_and_unit" },
+            "id": { "$ref": "#/definitions/id" },
+            "ids": { "$ref": "#/definitions/ids" }
+          },
+          "required" : [ "name", "radius" ],
+         "additionalProperties": false
+        }
+      ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ]
+    },
+
+    "engineering_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["EngineeringCRS"] },
+        "name": { "type": "string" },
+        "datum": { "$ref": "#/definitions/engineering_datum" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "datum" ],
+      "additionalProperties": false
+    },
+
+    "engineering_datum": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["EngineeringDatum"] },
+        "name": { "type": "string" },
+        "anchor": { "type": "string" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name" ],
+      "additionalProperties": false
+    },
+
+    "geodetic_crs": {
+      "type": "object",
+      "properties": {
+        "type": { "type": "string", "enum": ["GeodeticCRS", "GeographicCRS"] },
+        "name": { "type": "string" },
+        "datum": {
+            "oneOf": [
+                { "$ref": "#/definitions/geodetic_reference_frame" },
+                { "$ref": "#/definitions/dynamic_geodetic_reference_frame" }
+            ]
+        },
+        "datum_ensemble": { "$ref": "#/definitions/datum_ensemble" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "deformation_models": {
+          "type": "array",
+          "items": { "$ref": "#/definitions/deformation_model" }
+        },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name" ],
+      "description": "One and only one of datum and datum_ensemble must be provided",
+      "allOf": [
+        { "$ref": "#/definitions/object_usage" },
+        { "$ref": "#/definitions/one_and_only_one_of_datum_or_datum_ensemble" }
+      ],
+      "additionalProperties": false
+    },
+
+    "geodetic_reference_frame": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["GeodeticReferenceFrame"] },
+        "name": { "type": "string" },
+        "anchor": { "type": "string" },
+        "anchor_epoch": { "type": "number" },
+        "ellipsoid": { "$ref": "#/definitions/ellipsoid" },
+        "prime_meridian": { "$ref": "#/definitions/prime_meridian" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "ellipsoid" ],
+      "additionalProperties": false
+    },
+
+    "geoid_model": {
+      "type": "object",
+      "properties": {
+        "name": { "type": "string" },
+        "interpolation_crs": { "$ref": "#/definitions/crs" },
+        "id": { "$ref": "#/definitions/id" }
+      },
+      "required" : [ "name" ],
+      "additionalProperties": false
+    },
+
+    "id": {
+      "type": "object",
+      "properties": {
+        "authority": { "type": "string" },
+        "code": {
+          "oneOf": [ { "type": "string" }, { "type": "integer" } ]
+        },
+        "version": {
+          "oneOf": [ { "type": "string" }, { "type": "number" } ]
+        },
+        "authority_citation": { "type": "string" },
+        "uri": { "type": "string" }
+      },
+      "required" : [ "authority", "code" ],
+      "additionalProperties": false
+    },
+
+    "ids": {
+      "type": "array",
+      "items": { "$ref": "#/definitions/id" }
+    },
+
+    "method": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["OperationMethod"]},
+        "name": { "type": "string" },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "id_ids_mutually_exclusive": {
+        "not": {
+            "type": "object",
+            "required": [ "id", "ids" ]
+        }
+    },
+
+    "one_and_only_one_of_datum_or_datum_ensemble": {
+      "allOf": [
+        {
+            "not": {
+                "type": "object",
+                "required": [ "datum", "datum_ensemble" ]
+            }
+        },
+        {
+            "oneOf": [
+                { "type": "object", "required": ["datum"] },
+                { "type": "object", "required": ["datum_ensemble"] }
+            ]
+        }
+      ]
+    },
+
+    "meridian": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["Meridian"] },
+        "longitude": { "$ref": "#/definitions/value_in_degree_or_value_and_unit" },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "longitude" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "object_usage": {
+      "anyOf": [
+      {
+        "type": "object",
+        "properties": {
+            "$schema" : { "type": "string" },
+            "scope": { "type": "string" },
+            "area": { "type": "string" },
+            "bbox": { "$ref": "#/definitions/bbox" },
+            "vertical_extent": { "$ref": "#/definitions/vertical_extent" },
+            "temporal_extent": { "$ref": "#/definitions/temporal_extent" },
+            "remarks": { "type": "string" },
+            "id": { "$ref": "#/definitions/id" },
+            "ids": { "$ref": "#/definitions/ids" }
+        },
+        "allOf": [
+            { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+        ]
+      },
+      {
+        "type": "object",
+        "properties": {
+            "$schema" : { "type": "string" },
+            "usages": { "$ref": "#/definitions/usages" },
+            "remarks": { "type": "string" },
+            "id": { "$ref": "#/definitions/id" },
+            "ids": { "$ref": "#/definitions/ids" }
+        },
+        "allOf": [
+            { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+        ]
+      }
+      ]
+    },
+
+    "parameter_value": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["ParameterValue"] },
+        "name": { "type": "string" },
+        "value": {
+          "oneOf": [
+            { "type": "string" },
+            { "type": "number" }
+           ]
+        },
+        "unit": { "$ref": "#/definitions/unit" },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name", "value" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "parametric_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["ParametricCRS"] },
+        "name": { "type": "string" },
+        "datum": { "$ref": "#/definitions/parametric_datum" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "datum" ],
+      "additionalProperties": false
+    },
+
+    "parametric_datum": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["ParametricDatum"] },
+        "name": { "type": "string" },
+        "anchor": { "type": "string" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name" ],
+      "additionalProperties": false
+    },
+
+    "point_motion_operation": {
+      "$comment": "Not implemented in PROJ (at least as of PROJ 9.1)",
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["PointMotionOperation"] },
+        "name": { "type": "string" },
+        "source_crs": { "$ref": "#/definitions/crs" },
+        "method": { "$ref": "#/definitions/method" },
+        "parameters": {
+            "type": "array",
+            "items": { "$ref": "#/definitions/parameter_value" }
+        },
+        "accuracy": { "type": "string" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "source_crs", "method", "parameters" ],
+      "additionalProperties": false
+    },
+
+    "prime_meridian": {
+      "type": "object",
+      "properties": {
+        "$schema" : { "type": "string" },
+        "type": { "type": "string", "enum": ["PrimeMeridian"] },
+        "name": { "type": "string" },
+        "longitude": { "$ref": "#/definitions/value_in_degree_or_value_and_unit" },
+        "id": { "$ref": "#/definitions/id" },
+        "ids": { "$ref": "#/definitions/ids" }
+      },
+      "required" : [ "name" ],
+      "allOf": [
+        { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+      ],
+      "additionalProperties": false
+    },
+
+    "single_operation": {
+      "oneOf": [
+        { "$ref": "#/definitions/conversion" },
+        { "$ref": "#/definitions/transformation" },
+        { "$ref": "#/definitions/point_motion_operation" }
+      ]
+    },
+
+    "projected_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string",
+                  "enum": ["ProjectedCRS"] },
+        "name": { "type": "string" },
+        "base_crs": { "$ref": "#/definitions/geodetic_crs" },
+        "conversion": { "$ref": "#/definitions/conversion" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+     },
+     "required" : [ "name", "base_crs", "conversion", "coordinate_system" ],
+     "additionalProperties": false
+    },
+
+    "temporal_crs": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["TemporalCRS"] },
+        "name": { "type": "string" },
+        "datum": { "$ref": "#/definitions/temporal_datum" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "datum" ],
+      "additionalProperties": false
+    },
+
+    "temporal_datum": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["TemporalDatum"] },
+        "name": { "type": "string" },
+        "calendar": { "type": "string" },
+        "time_origin": { "type": "string" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "calendar" ],
+      "additionalProperties": false
+    },
+
+    "temporal_extent": {
+      "type": "object",
+      "properties": {
+        "start": { "type": "string" },
+        "end": { "type": "string" }
+      },
+      "required" : [ "start", "end" ],
+      "additionalProperties": false
+    },
+
+    "transformation": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["Transformation"] },
+        "name": { "type": "string" },
+        "source_crs": { "$ref": "#/definitions/crs" },
+        "target_crs": { "$ref": "#/definitions/crs" },
+        "interpolation_crs": { "$ref": "#/definitions/crs" },
+        "method": { "$ref": "#/definitions/method" },
+        "parameters": {
+            "type": "array",
+            "items": { "$ref": "#/definitions/parameter_value" }
+        },
+        "accuracy": { "type": "string" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name", "source_crs", "target_crs", "method", "parameters" ],
+      "additionalProperties": false
+    },
+
+    "unit": {
+      "oneOf": [
+      {
+        "type": "string",
+        "enum": ["metre", "degree", "unity"]
+      },
+      {
+        "type": "object",
+        "properties": {
+          "type": { "type": "string",
+                    "enum": ["LinearUnit", "AngularUnit", "ScaleUnit",
+                             "TimeUnit", "ParametricUnit", "Unit"] },
+          "name": { "type": "string" },
+          "conversion_factor": { "type": "number" },
+          "id": { "$ref": "#/definitions/id" },
+          "ids": { "$ref": "#/definitions/ids" }
+         },
+         "required" : [ "type", "name" ],
+         "allOf": [
+            { "$ref": "#/definitions/id_ids_mutually_exclusive" }
+          ],
+         "additionalProperties": false
+      }
+      ]
+    },
+
+    "usages": {
+        "type": "array",
+        "items": {
+          "type": "object",
+          "properties": {
+            "scope": { "type": "string" },
+            "area": { "type": "string" },
+            "bbox": { "$ref": "#/definitions/bbox" },
+            "vertical_extent": { "$ref": "#/definitions/vertical_extent" },
+            "temporal_extent": { "$ref": "#/definitions/temporal_extent" }
+           },
+          "additionalProperties": false
+        }
+    },
+
+    "value_and_unit": {
+      "type": "object",
+      "properties": {
+        "value": { "type": "number" },
+        "unit": { "$ref": "#/definitions/unit" }
+      },
+      "required" : [ "value", "unit" ],
+      "additionalProperties": false
+    },
+
+    "value_in_degree_or_value_and_unit": {
+      "oneOf": [
+        { "type": "number" },
+        { "$ref": "#/definitions/value_and_unit" }
+      ]
+    },
+
+    "value_in_metre_or_value_and_unit": {
+      "oneOf": [
+        { "type": "number" },
+        { "$ref": "#/definitions/value_and_unit" }
+      ]
+    },
+
+    "vertical_crs": {
+      "type": "object",
+      "properties": {
+        "type": { "type": "string", "enum": ["VerticalCRS"] },
+        "name": { "type": "string" },
+        "datum": {
+            "oneOf": [
+                { "$ref": "#/definitions/vertical_reference_frame" },
+                { "$ref": "#/definitions/dynamic_vertical_reference_frame" }
+            ]
+        },
+        "datum_ensemble": { "$ref": "#/definitions/datum_ensemble" },
+        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
+        "geoid_model": { "$ref": "#/definitions/geoid_model" },
+        "geoid_models": {
+          "type": "array",
+          "items": { "$ref": "#/definitions/geoid_model" }
+        },
+        "deformation_models": {
+          "type": "array",
+          "items": { "$ref": "#/definitions/deformation_model" }
+        },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name"],
+      "description": "One and only one of datum and datum_ensemble must be provided",
+      "allOf": [
+        { "$ref": "#/definitions/object_usage" },
+        { "$ref": "#/definitions/one_and_only_one_of_datum_or_datum_ensemble" },
+        {
+            "not": {
+                "type": "object",
+                "required": [ "geoid_model", "geoid_models" ]
+            }
+        }
+      ],
+      "additionalProperties": false
+    },
+
+    "vertical_extent": {
+      "type": "object",
+      "properties": {
+        "minimum": { "type": "number" },
+        "maximum": { "type": "number" },
+        "unit": { "$ref": "#/definitions/unit" }
+      },
+      "required" : [ "minimum", "maximum" ],
+      "additionalProperties": false
+    },
+
+    "vertical_reference_frame": {
+      "type": "object",
+      "allOf": [{ "$ref": "#/definitions/object_usage" }],
+      "properties": {
+        "type": { "type": "string", "enum": ["VerticalReferenceFrame"] },
+        "name": { "type": "string" },
+        "anchor": { "type": "string" },
+        "anchor_epoch": { "type": "number" },
+        "$schema" : {},
+        "scope": {},
+        "area": {},
+        "bbox": {},
+        "vertical_extent": {},
+        "temporal_extent": {},
+        "usages": {},
+        "remarks": {},
+        "id": {}, "ids": {}
+      },
+      "required" : [ "name" ],
+      "additionalProperties": false
+    }
+
+  }
+}
diff --git a/internal/validator/testdata/.gitignore b/internal/validator/testdata/.gitignore
new file mode 100644
index 0000000..a083d87
--- /dev/null
+++ b/internal/validator/testdata/.gitignore
@@ -0,0 +1,1 @@
+actual.json
diff --git a/internal/validator/testdata/all-pass-meta/expected.json b/internal/validator/testdata/all-pass-meta/expected.json
new file mode 100644
index 0000000..69b2217
--- /dev/null
+++ b/internal/validator/testdata/all-pass-meta/expected.json
@@ -0,0 +1,85 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": true
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/all-pass-meta/input.json b/internal/validator/testdata/all-pass-meta/input.json
new file mode 100644
index 0000000..1765d65
--- /dev/null
+++ b/internal/validator/testdata/all-pass-meta/input.json
@@ -0,0 +1,27 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/all-pass-minimal/expected.json b/internal/validator/testdata/all-pass-minimal/expected.json
new file mode 100644
index 0000000..2b2da11
--- /dev/null
+++ b/internal/validator/testdata/all-pass-minimal/expected.json
@@ -0,0 +1,105 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/all-pass-minimal/input.json b/internal/validator/testdata/all-pass-minimal/input.json
new file mode 100644
index 0000000..1765d65
--- /dev/null
+++ b/internal/validator/testdata/all-pass-minimal/input.json
@@ -0,0 +1,27 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/all-pass/expected.json b/internal/validator/testdata/all-pass/expected.json
new file mode 100644
index 0000000..2b2da11
--- /dev/null
+++ b/internal/validator/testdata/all-pass/expected.json
@@ -0,0 +1,105 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/all-pass/input.json b/internal/validator/testdata/all-pass/input.json
new file mode 100644
index 0000000..555dc20
--- /dev/null
+++ b/internal/validator/testdata/all-pass/input.json
@@ -0,0 +1,76 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [
+          "Point"
+        ],
+        "orientation": "counterclockwise",
+        "edges": "planar",
+        "bbox": [
+          0,
+          0,
+          0,
+          0
+        ],
+        "epoch": 2021.47,
+        "crs": {
+          "$schema": "https://proj.org/schemas/v0.5/projjson.schema.json",
+          "type": "GeographicCRS",
+          "name": "WGS 84 longitude-latitude",
+          "datum": {
+            "type": "GeodeticReferenceFrame",
+            "name": "World Geodetic System 1984",
+            "ellipsoid": {
+              "name": "WGS 84",
+              "semi_major_axis": 6378137,
+              "inverse_flattening": 298.257223563
+            }
+          },
+          "coordinate_system": {
+            "subtype": "ellipsoidal",
+            "axis": [
+              {
+                "name": "Geodetic longitude",
+                "abbreviation": "Lon",
+                "direction": "east",
+                "unit": "degree"
+              },
+              {
+                "name": "Geodetic latitude",
+                "abbreviation": "Lat",
+                "direction": "north",
+                "unit": "degree"
+              }
+            ]
+          },
+          "id": {
+            "authority": "OGC",
+            "code": "CRS84"
+          }
+        }
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [
+            0,
+            0
+          ]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-bbox-length/expected.json b/internal/validator/testdata/bad-bbox-length/expected.json
new file mode 100644
index 0000000..a3febca
--- /dev/null
+++ b/internal/validator/testdata/bad-bbox-length/expected.json
@@ -0,0 +1,107 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": false,
+      "message": "expected \"bbox\" for column \"geometry\" to be a list of 4 or 6 numbers, got [-1,1]"
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": false,
+      "message": "invalid bbox length for column \"geometry\""
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-bbox-length/input.json b/internal/validator/testdata/bad-bbox-length/input.json
new file mode 100644
index 0000000..a3f04e1
--- /dev/null
+++ b/internal/validator/testdata/bad-bbox-length/input.json
@@ -0,0 +1,28 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "bbox": [-1, 1]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-bbox-type/expected.json b/internal/validator/testdata/bad-bbox-type/expected.json
new file mode 100644
index 0000000..7e628bf
--- /dev/null
+++ b/internal/validator/testdata/bad-bbox-type/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": false,
+      "message": "expected \"bbox\" for column \"geometry\" to be a list of numbers, got [\"not\",\"a\",\"bounding\",\"box\"]"
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": false,
+      "passed": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-bbox-type/input.json b/internal/validator/testdata/bad-bbox-type/input.json
new file mode 100644
index 0000000..49216ea
--- /dev/null
+++ b/internal/validator/testdata/bad-bbox-type/input.json
@@ -0,0 +1,28 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "bbox": ["not", "a", "bounding", "box"]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-crs/expected.json b/internal/validator/testdata/bad-crs/expected.json
new file mode 100644
index 0000000..5d78150
--- /dev/null
+++ b/internal/validator/testdata/bad-crs/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": false,
+      "message": "validation failed against https://proj.org/schemas/v0.6/projjson.schema.json: input is invalid: missing properties: 'source_crs', 'target_crs', 'transformation'"
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-crs/input.json b/internal/validator/testdata/bad-crs/input.json
new file mode 100644
index 0000000..1a4afe1
--- /dev/null
+++ b/internal/validator/testdata/bad-crs/input.json
@@ -0,0 +1,33 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "crs": {
+          "type": "BogusCRS"
+        }
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [
+            0,
+            0
+          ]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-edges/expected.json b/internal/validator/testdata/bad-edges/expected.json
new file mode 100644
index 0000000..cc73c66
--- /dev/null
+++ b/internal/validator/testdata/bad-edges/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": false,
+      "message": "unsupported edges \"bogus\" for column \"geometry\", expected \"planar\" or \"spherical\""
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-edges/input.json b/internal/validator/testdata/bad-edges/input.json
new file mode 100644
index 0000000..fdb7369
--- /dev/null
+++ b/internal/validator/testdata/bad-edges/input.json
@@ -0,0 +1,28 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "edges": "bogus"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-encoding/expected.json b/internal/validator/testdata/bad-encoding/expected.json
new file mode 100644
index 0000000..a53e60e
--- /dev/null
+++ b/internal/validator/testdata/bad-encoding/expected.json
@@ -0,0 +1,107 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": false,
+      "message": "unsupported encoding \"bogus\" for column \"geometry\""
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": false,
+      "message": "invalid geometry in column \"geometry\": unsupported encoding: bogus"
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": false,
+      "passed": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-encoding/input.json b/internal/validator/testdata/bad-encoding/input.json
new file mode 100644
index 0000000..7852578
--- /dev/null
+++ b/internal/validator/testdata/bad-encoding/input.json
@@ -0,0 +1,27 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "bogus",
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-epoch/expected.json b/internal/validator/testdata/bad-epoch/expected.json
new file mode 100644
index 0000000..3808ac8
--- /dev/null
+++ b/internal/validator/testdata/bad-epoch/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": false,
+      "message": "expected \"epoch\" for column \"geometry\" to be a number, got \"bogus\""
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": false,
+      "passed": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-epoch/input.json b/internal/validator/testdata/bad-epoch/input.json
new file mode 100644
index 0000000..c78f28b
--- /dev/null
+++ b/internal/validator/testdata/bad-epoch/input.json
@@ -0,0 +1,28 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "epoch": "bogus"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-geometry-types/expected.json b/internal/validator/testdata/bad-geometry-types/expected.json
new file mode 100644
index 0000000..81c5534
--- /dev/null
+++ b/internal/validator/testdata/bad-geometry-types/expected.json
@@ -0,0 +1,107 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": false,
+      "message": "unsupported geometry type \"bogus\" for column \"geometry\""
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": false,
+      "message": "unexpected geometry type \"Point\" for column \"geometry\""
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-geometry-types/input.json b/internal/validator/testdata/bad-geometry-types/input.json
new file mode 100644
index 0000000..1bd744c
--- /dev/null
+++ b/internal/validator/testdata/bad-geometry-types/input.json
@@ -0,0 +1,27 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": ["bogus"]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-metadata-type/expected.json b/internal/validator/testdata/bad-metadata-type/expected.json
new file mode 100644
index 0000000..c86f9cb
--- /dev/null
+++ b/internal/validator/testdata/bad-metadata-type/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": false,
+      "message": "failed to parse file metadata as a JSON object"
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": false,
+      "passed": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-metadata-type/input.json b/internal/validator/testdata/bad-metadata-type/input.json
new file mode 100644
index 0000000..7dcc489
--- /dev/null
+++ b/internal/validator/testdata/bad-metadata-type/input.json
@@ -0,0 +1,18 @@
+{
+  "metadata": "bad metadata",
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-orientation/expected.json b/internal/validator/testdata/bad-orientation/expected.json
new file mode 100644
index 0000000..d4d01c3
--- /dev/null
+++ b/internal/validator/testdata/bad-orientation/expected.json
@@ -0,0 +1,107 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": false,
+      "message": "unsupported orientation \"bogus\" for column \"geometry\", expected \"counterclockwise\""
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": false,
+      "message": "unsupported orientation \"bogus\" for column \"geometry\""
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-orientation/input.json b/internal/validator/testdata/bad-orientation/input.json
new file mode 100644
index 0000000..aba4b78
--- /dev/null
+++ b/internal/validator/testdata/bad-orientation/input.json
@@ -0,0 +1,28 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "orientation": "bogus"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-primary-column/expected.json b/internal/validator/testdata/bad-primary-column/expected.json
new file mode 100644
index 0000000..4da4c4d
--- /dev/null
+++ b/internal/validator/testdata/bad-primary-column/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": false,
+      "message": "the \"bogus\" column is not included in the column metadata"
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-primary-column/input.json b/internal/validator/testdata/bad-primary-column/input.json
new file mode 100644
index 0000000..d2760e3
--- /dev/null
+++ b/internal/validator/testdata/bad-primary-column/input.json
@@ -0,0 +1,27 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "bogus",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-correctly-oriented/expected.json b/internal/validator/testdata/geometry-correctly-oriented/expected.json
new file mode 100644
index 0000000..2b2da11
--- /dev/null
+++ b/internal/validator/testdata/geometry-correctly-oriented/expected.json
@@ -0,0 +1,105 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-correctly-oriented/input.json b/internal/validator/testdata/geometry-correctly-oriented/input.json
new file mode 100644
index 0000000..60fb467
--- /dev/null
+++ b/internal/validator/testdata/geometry-correctly-oriented/input.json
@@ -0,0 +1,31 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "orientation": "counterclockwise"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Right Hand Rule"
+        },
+        "geometry": {
+          "type": "Polygon",
+          "coordinates": [
+            [[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]],
+            [[-5, -5], [-5, 5], [5, 5], [5, -5], [-5, -5]]
+          ]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-incorrectly-oriented/expected.json b/internal/validator/testdata/geometry-incorrectly-oriented/expected.json
new file mode 100644
index 0000000..5cb14ec
--- /dev/null
+++ b/internal/validator/testdata/geometry-incorrectly-oriented/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": false,
+      "message": "invalid orientation for exterior ring in column \"geometry\""
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-incorrectly-oriented/input.json b/internal/validator/testdata/geometry-incorrectly-oriented/input.json
new file mode 100644
index 0000000..8d18cda
--- /dev/null
+++ b/internal/validator/testdata/geometry-incorrectly-oriented/input.json
@@ -0,0 +1,31 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "orientation": "counterclockwise"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Right Hand Rule"
+        },
+        "geometry": {
+          "type": "Polygon",
+          "coordinates": [
+            [[-10, -10], [-10, 10], [10, 10], [10, -10], [-10, -10]],
+            [[-5, -5], [-5, 5], [5, 5], [5, -5], [-5, -5]]
+          ]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-inside-antimeridian-spanning-bbox/expected.json b/internal/validator/testdata/geometry-inside-antimeridian-spanning-bbox/expected.json
new file mode 100644
index 0000000..2b2da11
--- /dev/null
+++ b/internal/validator/testdata/geometry-inside-antimeridian-spanning-bbox/expected.json
@@ -0,0 +1,105 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-inside-antimeridian-spanning-bbox/input.json b/internal/validator/testdata/geometry-inside-antimeridian-spanning-bbox/input.json
new file mode 100644
index 0000000..7bef967
--- /dev/null
+++ b/internal/validator/testdata/geometry-inside-antimeridian-spanning-bbox/input.json
@@ -0,0 +1,34 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "bbox": [170, -10, -170, 10]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {},
+        "geometry": {
+          "type": "Point",
+          "coordinates": [175, 0]
+        }
+      },
+      {
+        "type": "Feature",
+        "properties": {},
+        "geometry": {
+          "type": "Point",
+          "coordinates": [-175, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-outside-antimeridian-spanning-bbox/expected.json b/internal/validator/testdata/geometry-outside-antimeridian-spanning-bbox/expected.json
new file mode 100644
index 0000000..52ed2c2
--- /dev/null
+++ b/internal/validator/testdata/geometry-outside-antimeridian-spanning-bbox/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": false,
+      "message": "geometry in column \"geometry\" extends to -155.000000, outside of the bbox"
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-outside-antimeridian-spanning-bbox/input.json b/internal/validator/testdata/geometry-outside-antimeridian-spanning-bbox/input.json
new file mode 100644
index 0000000..7952c36
--- /dev/null
+++ b/internal/validator/testdata/geometry-outside-antimeridian-spanning-bbox/input.json
@@ -0,0 +1,34 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "bbox": [170, -10, -170, 10]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {},
+        "geometry": {
+          "type": "Point",
+          "coordinates": [175, 0]
+        }
+      },
+      {
+        "type": "Feature",
+        "properties": {},
+        "geometry": {
+          "type": "Point",
+          "coordinates": [-155, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-outside-bbox/expected.json b/internal/validator/testdata/geometry-outside-bbox/expected.json
new file mode 100644
index 0000000..0335eaa
--- /dev/null
+++ b/internal/validator/testdata/geometry-outside-bbox/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": false,
+      "message": "geometry in column \"geometry\" extends to 20.000000, east of the bbox"
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-outside-bbox/input.json b/internal/validator/testdata/geometry-outside-bbox/input.json
new file mode 100644
index 0000000..0d98d48
--- /dev/null
+++ b/internal/validator/testdata/geometry-outside-bbox/input.json
@@ -0,0 +1,26 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "bbox": [-10, -10, 10, 10]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {},
+        "geometry": {
+          "type": "Point",
+          "coordinates": [20, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-type-not-in-list/expected.json b/internal/validator/testdata/geometry-type-not-in-list/expected.json
new file mode 100644
index 0000000..845ef35
--- /dev/null
+++ b/internal/validator/testdata/geometry-type-not-in-list/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": false,
+      "message": "unexpected geometry type \"Point\" for column \"geometry\""
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/geometry-type-not-in-list/input.json b/internal/validator/testdata/geometry-type-not-in-list/input.json
new file mode 100644
index 0000000..6138bde
--- /dev/null
+++ b/internal/validator/testdata/geometry-type-not-in-list/input.json
@@ -0,0 +1,27 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": ["Polygon"]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-columns/expected.json b/internal/validator/testdata/missing-columns/expected.json
new file mode 100644
index 0000000..fd0bf60
--- /dev/null
+++ b/internal/validator/testdata/missing-columns/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": false,
+      "message": "missing \"columns\" in metadata"
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": false,
+      "passed": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-columns/input.json b/internal/validator/testdata/missing-columns/input.json
new file mode 100644
index 0000000..e1306ab
--- /dev/null
+++ b/internal/validator/testdata/missing-columns/input.json
@@ -0,0 +1,21 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry"
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-encoding/expected.json b/internal/validator/testdata/missing-encoding/expected.json
new file mode 100644
index 0000000..207a565
--- /dev/null
+++ b/internal/validator/testdata/missing-encoding/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": false,
+      "message": "missing \"encoding\" for column \"geometry\""
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-encoding/input.json b/internal/validator/testdata/missing-encoding/input.json
new file mode 100644
index 0000000..7ef88d1
--- /dev/null
+++ b/internal/validator/testdata/missing-encoding/input.json
@@ -0,0 +1,26 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-geometry-types/expected.json b/internal/validator/testdata/missing-geometry-types/expected.json
new file mode 100644
index 0000000..54c6113
--- /dev/null
+++ b/internal/validator/testdata/missing-geometry-types/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": false,
+      "message": "missing \"geometry_types\" for column \"geometry\""
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-geometry-types/input.json b/internal/validator/testdata/missing-geometry-types/input.json
new file mode 100644
index 0000000..9675ce8
--- /dev/null
+++ b/internal/validator/testdata/missing-geometry-types/input.json
@@ -0,0 +1,26 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-primary-column/expected.json b/internal/validator/testdata/missing-primary-column/expected.json
new file mode 100644
index 0000000..5edb22d
--- /dev/null
+++ b/internal/validator/testdata/missing-primary-column/expected.json
@@ -0,0 +1,107 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": false,
+      "message": "missing \"primary_column\" in metadata"
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": false,
+      "message": "the \"\" column is not included in the column metadata"
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-primary-column/input.json b/internal/validator/testdata/missing-primary-column/input.json
new file mode 100644
index 0000000..1b7fc9e
--- /dev/null
+++ b/internal/validator/testdata/missing-primary-column/input.json
@@ -0,0 +1,26 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-version/expected.json b/internal/validator/testdata/missing-version/expected.json
new file mode 100644
index 0000000..e2e6d9a
--- /dev/null
+++ b/internal/validator/testdata/missing-version/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": false,
+      "message": "missing \"version\" in metadata"
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": true,
+      "passed": true
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/missing-version/input.json b/internal/validator/testdata/missing-version/input.json
new file mode 100644
index 0000000..cdeeead
--- /dev/null
+++ b/internal/validator/testdata/missing-version/input.json
@@ -0,0 +1,26 @@
+{
+  "metadata": {
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": []
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go
index 3a9df45..1d59839 100644
--- a/internal/validator/validator_test.go
+++ b/internal/validator/validator_test.go
@@ -15,16 +15,21 @@
 package validator_test
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"io"
 	"net/url"
 	"os"
 	"path"
+	"strings"
 	"testing"
 
+	"github.com/planetlabs/gpq/internal/geojson"
 	"github.com/planetlabs/gpq/internal/validator"
 	"github.com/santhosh-tekuri/jsonschema/v5"
+	"github.com/segmentio/encoding/json"
+	"github.com/segmentio/parquet-go"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -47,6 +52,54 @@ type Suite struct {
 	originalHttpsLoader func(string) (io.ReadCloser, error)
 }
 
+func (s *Suite) readExpected(name string) []byte {
+	expected, err := os.ReadFile(path.Join("testdata", name, "expected.json"))
+	s.Require().NoError(err)
+	return expected
+}
+
+func (s *Suite) writeActual(name string, data []byte) {
+	err := os.WriteFile(path.Join("testdata", name, "actual.json"), data, 0644)
+	s.Require().NoError(err)
+}
+
+type Spec struct {
+	Metadata json.RawMessage `json:"metadata"`
+	Data     json.RawMessage `json:"data"`
+}
+
+func (s *Suite) readSpec(name string) *Spec {
+	data, err := os.ReadFile(path.Join("testdata", name, "input.json"))
+	s.Require().NoError(err)
+	input := &Spec{}
+	s.Require().NoError(json.Unmarshal(data, input))
+	return input
+}
+
+func (s *Suite) generateGeoParquet(name string) *parquet.File {
+	spec := s.readSpec(name)
+
+	output := &bytes.Buffer{}
+
+	options := &geojson.ConvertOptions{
+		Metadata: string(spec.Metadata),
+	}
+	s.Require().NoError(geojson.ToParquet(bytes.NewReader(spec.Data), output, options))
+
+	file, err := parquet.OpenFile(bytes.NewReader(output.Bytes()), int64(output.Len()))
+	s.Require().NoError(err)
+
+	return file
+}
+
+func (s *Suite) assertExpectedReport(name string, report *validator.Report) {
+	actual, err := json.MarshalIndent(report, "", "  ")
+	s.Require().NoError(err)
+	s.writeActual(name, actual)
+	expected := s.readExpected(name)
+	s.Assert().JSONEq(string(expected), string(actual))
+}
+
 func (s *Suite) SetupSuite() {
 	s.originalHttpLoader = jsonschema.Loaders["http"]
 	s.originalHttpsLoader = jsonschema.Loaders["https"]
@@ -59,19 +112,69 @@ func (s *Suite) TearDownSuite() {
 	jsonschema.Loaders["https"] = s.originalHttpsLoader
 }
 
-func (s *Suite) TestValidCases() {
+func (s *Suite) XTestValidCases() {
 	cases := []string{
-		"example-v0.4.0.parquet",
 		"example-v1.0.0-beta.1.parquet",
 	}
 
-	v := validator.New()
+	validatorAll := validator.New(false)
+	validatorMeta := validator.New(true)
+
 	ctx := context.Background()
+
 	for _, c := range cases {
 		s.Run(c, func() {
 			resourcePath := path.Join("../testdata/cases", c)
-			err := v.Validate(ctx, resourcePath)
-			s.Assert().NoError(err)
+
+			allReport, allErr := validatorAll.Validate(ctx, resourcePath)
+			s.Require().NoError(allErr)
+			s.assertExpectedReport("all-pass", allReport)
+
+			metaReport, metaErr := validatorMeta.Validate(ctx, resourcePath)
+			s.Require().NoError(metaErr)
+			s.assertExpectedReport("all-pass-meta", metaReport)
+		})
+	}
+}
+
+func (s *Suite) TestReport() {
+	cases := []string{
+		"all-pass",
+		"all-pass-meta",
+		"all-pass-minimal",
+		"bad-metadata-type",
+		"missing-version",
+		"missing-primary-column",
+		"missing-columns",
+		"bad-primary-column",
+		"missing-encoding",
+		"bad-encoding",
+		"missing-geometry-types",
+		"bad-geometry-types",
+		"bad-crs",
+		"bad-orientation",
+		"bad-edges",
+		"bad-bbox-type",
+		"bad-bbox-length",
+		"bad-epoch",
+		"geometry-type-not-in-list",
+		"geometry-correctly-oriented",
+		"geometry-incorrectly-oriented",
+		"geometry-outside-bbox",
+		"geometry-inside-antimeridian-spanning-bbox",
+		"geometry-outside-antimeridian-spanning-bbox",
+	}
+
+	ctx := context.Background()
+	for _, c := range cases {
+		s.Run(c, func() {
+			metadataOnly := strings.HasSuffix(c, "-meta")
+			v := validator.New(metadataOnly)
+
+			report, err := v.Report(ctx, s.generateGeoParquet(c))
+			s.Require().NoError(err)
+
+			s.assertExpectedReport(c, report)
 		})
 	}
 }