Skip to content

Schema languages

Both are first class. JSON Schema is the natural fit for CloudEvents' JSON payloads; protobuf descriptors matter because gRPC services in this estate would register too, and a registry serving only one would send the other somewhere else.

The cost, stated: everything language-specific doubles. The buf schema registry exists in large part because Confluent's serves Avro well and leaves protobuf users hand-sharing .proto files, so somebody else has already paid for this lesson.

JSON Schema

schema.JSONSchema. Bytes is the schema document. Root is unused — a JSON Schema is its own root.

schema.Schema{
    URN:      urn.Schema{Name: "orders.created", Version: 3},
    Language: schema.JSONSchema,
    Bytes:    []byte(`{"type":"object","required":["order"]}`),
}

Validation is what you would expect: required fields, types, minimum, additionalProperties, the lot. A payload that is not JSON is ErrInvalidPayload; a schema document that will not compile is ErrSchemaUnusable, because the two have different owners and land on different sides of the guard's fail-open line.

References outside the schema are refused. The compiler's default loader resolves a file: URL by reading local disk, so {"$ref":"file:///etc/passwd"} would make validation depend on the filesystem of whichever replica handled the message — two replicas then disagreeing about one message, which is the non-reproducibility immutability exists to prevent. A schema is self-contained here.

Compiled schemas are cached by identifier and by a digest of the bytes. The identifier alone would be wrong: immutability is a property of one store, while a validator is a process-wide singleton fed by any resolver — an embedded register and a network client in the same process, or a test fake.

Protobuf

schema.Protobuf. Bytes is a serialised FileDescriptorSet. Root is required: it names the fully-qualified message a payload is.

schema.Schema{
    URN:      urn.Schema{Name: "orders.shipped", Version: 1},
    Language: schema.Protobuf,
    Bytes:    descriptorSet,
    Root:     "uk.phpboyscout.OrderShipped",
}

A descriptor set holds every message the target file depends on, and nothing in it says which one a payload is. Inferring the entry point by convention — the first message of the last file, say — is wrong exactly once, and then validates against a different message than anybody meant.

A missing root is ErrSchemaUnusable, so a guard fails open on it: a schema published without its root is our mistake, not the producer's. The cache is keyed on the root as well as the bytes, because the root is what selects the message.

Produce the descriptor set with protoc:

$ protoc --include_imports --descriptor_set_out=orders.shipped.v1.binpb orders.proto

--include_imports is not optional: without it the set is missing the types the target message depends on and will not resolve.

What the protobuf validator actually proves

Less than you would assume

Protobuf's wire format is deliberately permissive, and the validator inherits that:

Payload Result
Empty Valid. Every proto3 field is optional and takes its zero value
Contains an unknown field Valid. Unknown fields are preserved, by design
A field whose wire type does not match the descriptor Valid. It is treated as an unknown field and skipped
Truncated Invalid
Trailing bytes that do not decode Invalid

What is left is a real check on framing, and it is weaker than JSON Schema's because the format is weaker. Each of those three permissions is asserted in the test suite rather than left to be discovered.

If you need field-level guarantees for protobuf traffic, they belong in the application, not here.

Versions

orders.created:3, never :v3.1.0 and never :2026-08-28.

An integer is unambiguous, orders trivially, and claims nothing. Semantic versioning would imply a compatibility promise that nothing checks, and a date encodes when rather than what.

A version bump today asserts only that the bytes differ. It is not a compatibility claim, and nothing should read it as one.