Schema registry, backed by git

Every service that exchanges protobuf messages needs the same blueprints. The registry is where ProtoMolt keeps them, and its storage is an ordinary git repository: each accepted registration is one commit, history is a plain git log, and review and replication are whatever your git hosting already does.

In plain terms. A schema registry is a shared drawer of message blueprints: when one program sends a protobuf message, the receiver looks up the blueprint here by name. Most registries keep that drawer in a database. This one keeps it in a plain git repository, so registering a schema is a commit, undoing a mistake is normal git, and copying the whole registry to another site is a git remote. At the same time it speaks the industry-standard Confluent protocol over HTTP, so the serializers and clients you already have work against it without modification.
flowchart LR
        clients["Existing serializers and clients (Confluent protocol)"] --> srv["Registry HTTP server, JDK HttpServer on virtual threads, default port 8081"]
        srv --> store["Git-backed store: every registration is one commit"]
        store --> repo["Ordinary git repository: subjects/, descriptors/sha256/, registry.json"]
        srv -.-> extras["Native extras under /protomolt: descriptor sets, Parquet schema, typed configs"]
        other["Another mesh's registry"] -. "added as a git remote, pull only" .-> repo
The 10,000-foot view: stock Confluent clients on the front, a git repository as the storage, native extras alongside, and other meshes synced by git fetch.

Watch a registration become a commit

Every registration runs the same pipeline, in this order: verify that the schema's referenced schemas exist, short-circuit if identical content is already registered, run the compatibility gate for the subject's mode, compile the schema together with its references, and only then commit. Run the three scenarios below. The first adds an optional field and lands as a new commit node. The second changes a field's wire type and dies at the gate with the protocol's 409. The third registers byte-identical text and returns the existing version without touching history.

One subject, shop/v1/order.proto, already at v1. Step through a registration and watch what the gate lets into history. Without JavaScript you see the compatible scenario's end state: v2 accepted as one commit.

The subject's history, as commit nodes:

git log: 2 commits (+ v2.proto, + v2.json)

  1. Verify references

    The candidate imports shop/v1/common.proto, and every named reference must already exist in the registry. It does, at v3. A dangling reference ends the attempt here with ReferenceNotFoundException, before anything is written.

  2. Check idempotency

    Registration is idempotent by content identity: the schema text plus its references are hashed. This text is new, so the pipeline continues. Byte-identical content short-circuits here and returns the existing version unchanged, with no commit.

  3. Run the compatibility gate

    The subject's effective mode is BACKWARD, the global default: a new version must not break consumers compiled against the latest one, v1. The only change is a new optional field, currency = 4. Old readers skip unknown field numbers, so the gate passes. A *_TRANSITIVE mode would check every historical version, not just the latest.

  4. Compile

    The candidate and its resolved reference texts are compiled together through ProtoSourceCompiler, Square Wire's schema library, entirely in memory with no protoc binary. A parse or link failure ends here as InvalidSchemaException, which the protocol reports as 42201, invalid schema.

  5. Commit

    One commit writes the schema text to subjects/<subject>/v2.proto, its metadata (references, globalId, content hash) to v2.json, and stores the compiled descriptor set content-addressed under descriptors/sha256/. The global id increments and comes back as the registration id. History is now two commits, and git log reads it.

Done

HTTP 200 · registered

The response carries the new global id:

{"id": <new globalId>}

One commit: + v2.proto, + v2.json, and the compiled descriptor set under descriptors/sha256/. The subject now has two versions.

Two details in that animation carry real weight. First, the gate runs on the subject's effective mode, which is the per-subject setting when one exists and the global mode otherwise, and a mode of NONE skips the gate entirely. Second, compile happens after the gate but before the commit, so what lands in the repository is always something that parses and links. The idempotent path exists so publishers can safely re-register everything they hold: the lookup-by-content endpoint (POST /subjects/{subject}) answers "already there" without writing.

The repository is the storage

GitSchemaRegistryStore treats a git repository as the storage itself, not as an export format. Every register and every setCompatibilityMode is one commit against a non-bare working tree, so the registry's full history is a plain git log and a git pull in the right place is a replication event. The on-disk layout is fixed and human-readable:

schema/registry/core, GitSchemaRegistryStore javadoc: the on-disk layout of the registry repository

registry.json                              global compatibility mode + next globalId counter
subjects/<url-encoded-subject>/v<N>.proto  schema text
subjects/<url-encoded-subject>/v<N>.json   metadata: references, globalId, contentHash
subjects/<url-encoded-subject>/config.json per-subject compatibility mode, when set
workflows/<url-encoded-name>.json          stored workflow definition
workflow-versions/<name>/<version>.pb     immutable promoted version
descriptors/sha256/<fingerprint>.pb       content-addressed descriptor set

Subjects hold ascending 1-based versions, and every registered schema also gets a globalId that is unique and monotonic across the whole store. That id, not the subject or version, is what a serialized message references on the wire, which is why the stale-counter guard matters: on reload the store sets its next id to at least max seen id + 1, so out-of-band commits, say a git pull, can never cause an id to be reused.

Writes are serialized twice over: a shared per-path JVM ReentrantLock plus a registry.lock file lock, so two stores in the same JVM or in different processes cannot corrupt one repository. Reads never take a lock. They run off an in-memory index that is rebuilt on demand, invalidated after every write, and refreshed explicitly to pick up commits made by other tools. There is no delete anywhere in the served protocol: histories are append-only by design.

one commit per registration versions are 1-based, per subject global ids are monotonic, store-wide reads are lock-free server has zero framework dependencies

The Confluent subjects protocol, unchanged

The registry server (protomolt-registry-service) is a JDK HttpServer on virtual threads with no framework dependencies. Its role name is registry and its default port is 8081, the conventional schema registry port. The server itself does no TLS; deployments front it with TLS and the shared-secret token layer. It implements the Confluent subjects REST protocol, content type application/vnd.schemaregistry.v1+json, so stock serializers, the Confluent ecosystem, Apicurio's ccompat facade clients, and Redpanda clients all work as-is:

schema/registry/service, SchemaRegistryServer: the served route table (subjects protocol)

GET     /subjects
GET     /subjects/{subject}/versions
GET     /subjects/{subject}/versions/{version|latest}
POST    /subjects/{subject}/versions        register, returns {"id": …}
POST    /subjects/{subject}                  lookup by content (the idempotency check)
GET     /schemas/ids/{id}
GET|PUT /config                                 global compatibility mode
GET|PUT /config/{subject}                   per-subject compatibility mode

Subject path segments are URL-decoded, so import-path subjects containing slashes round-trip. There is one deliberate protocol quirk to know: PUT bodies and responses say compatibility, while GET responses say compatibilityLevel. That is Confluent's own inconsistency, reproduced so clients do not notice a difference. And PROTOBUF is the only accepted schemaType; anything else is refused as 42201, invalid schema.

Errors use Confluent's envelope shape, with the exact codes the ecosystem expects:

CodeMeaning
40401unknown subject
40402unknown version
40403schema not found
40408subject-level config unset
42201invalid schema (including a non-PROTOBUF schemaType)
42202invalid version
42203invalid compatibility level
409incompatible registration, the gate's refusal

The platform dogfoods this claim as its acceptance test: the round-trip suite publishes a reference-linked source set into the ProtoMolt registry with the stock ConfluentSchemaPublisher and reads it back with ConfluentSchemaRegistryLoader. Third-party conformance runs the same publish/load and compatibility checks against live Apicurio v3, Apicurio's ccompat facade, and Redpanda containers. The rule the tests encode: if those pass against us, we speak the protocol. The registry's live API is HTTP only: there is no registry gRPC service.

The gate and its seven modes

Compatibility modes are kept as opaque, validated strings, exactly the Confluent vocabulary: NONE, BACKWARD, FORWARD, FULL, BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, and FULL_TRANSITIVE. Anything else is refused outright. The global default is BACKWARD, matching Confluent. The built-in gate, CompatibilityWriteGate, is backed by the protomolt-compat library. Non-transitive modes compare the candidate against the latest version; the transitive variants compare it against every historical version.

The checker knows protobuf's real wire-compatibility groups, not just field numbers: varint-interchangeable integers (so an int32 to int64 widening is fine), the zigzag sint* family, fixed-width groups, the asymmetric string to bytes case, and open enums. It also tracks oneof moves, map changes, reserved-number reuse, proto2 required, and gRPC signature and streaming changes. Types match by fully-qualified name across the whole set, so moving a message between files is not a change. Wire-level rules are the default, matching Confluent's protobuf checking; JSON rules (which catch proto3-JSON breakage like field renames) and source rules (which treat generated-code breakage as a violation) are opt-in layers on the same engine. Every violation carries a rule id, a path, and a reason, and over HTTP the refusal is the 409 you saw in the animation.

Standing the store up with the gate attached is a builder call:

docs/schema/registry.md: standing up the git-backed store with the compatibility write gate, and serving it

Show the actual definition

var store = GitSchemaRegistryStore.builder()
    .repositoryDir(Path.of("/var/lib/protomolt/registry"))
    .writeGate(new CompatibilityWriteGate())
    .build();

var server = new SchemaRegistryServer(config, store);
server.start();

Federation by git remote

To federate, another mesh's git-backed registry is added as an ordinary git remote of this registry's repository. Sync is strictly pull: fetch, then read straight out of the fetched git objects, never merging into the working tree, then import each version through the normal registration pipeline. Remotes live in git config, which makes them node-local deployment facts rather than registry content, so adding one is not a commit.

flowchart LR
          b["Registry B's repository, in another mesh"] -- "git fetch (pull only)" --> a["Registry A's repository, remote already added"]
          a --> imp["Import each version: namespace subjects, rewrite references, run the gate"]
          imp --> a
          imp -. "diverged history: refused at the divergence point" .-> b
Federation: registry B is a git remote of registry A. Sync fetches and imports through the normal gated pipeline. Pull only, never push.

Imported subjects carry their origin. Remote subject s imports as <remote>:s, and a subject federated through two meshes reads as a provenance chain like b:a:s. References between remote subjects are rewritten to the namespaced names, while schema text is left untouched, because its import paths are the reference names. The sync path is always gated by a compatibility check under the target subject's effective local mode, even when the store's own write gate is absent. A version that fails is reported with its violations and stops that subject's import; other subjects continue. Ordering is a fixpoint loop rather than an explicit sort: a version whose rewritten references have not landed yet defers its subject to the next pass, and content-addressed descriptor blobs import verbatim when missing.

Re-running a sync is idempotent and reports versions that were already present. A remote whose history diverged from what was imported is refused at the divergence point, because registry histories are append-only and federation never rewrites what it already imported. Three things are deliberately never synced: the remote's registry.json (global ids and the global mode are local), per-subject compatibility modes (local policy), and workflows (deployment-specific). The whole surface is three contributed verbs with the SCHEMA_WRITE scope: registry-remotes (list, add, remove), registry-sync (fetch and import), and publish-config (typed config write). A designed wire contract for content-addressed descriptor-set sync between peers, DescriptorExchangeService, has no server implementation yet; real sync today is git federation plus the descriptor-set route.

Native extras under /protomolt

Alongside the stock protocol, a configurable single-segment prefix (default /protomolt) serves routes Confluent does not have:

  • Descriptor-set download. GET /protomolt/subjects/{subject}/descriptor-set returns the binary FileDescriptorSet of the subject's latest schema plus its transitive references, topologically ordered with dependencies first, so a consumer can link the whole set in one forward pass.
  • Parquet schema. GET /protomolt/subjects/{subject}/parquet-schema?message={fqn} derives the Parquet schema of one message as canonical text. It is derived on read, never stored.
  • Typed configs. GET /protomolt/configs and GET|PUT /protomolt/configs/{name} store config documents whose type must already be registered. A document is an envelope naming a message type plus canonical proto3 JSON, and the gate runs on write and on read: the type resolves against registered schemas, the JSON parses strictly into a DynamicMessage, and the type's own validate.v1 rules run. The config's version is the git commit id, which is why a hand-edited repository serves a readable refusal rather than an invalid document. The platform's config-registry module reads these documents over this same surface.
  • Token auth with scopes. When an API token is configured, every request except GET /health needs the shared secret as an api_token header or a Bearer credential. With a caller resolver mounted, credentials map to principals: reads need the schema-read scope, writes need schema-write, and each contributed verb applies its own scope. Request bodies are capped at 16 MiB (413 beyond that), and a 500 response never carries backend detail: the client gets a correlation id and the stack trace goes to the log.

The actions route (GET /protomolt/actions, POST /protomolt/actions/{name}) is also mounted there, and it is the only HTTP home for the contributed verbs, present only when an action catalog is passed in.