Request icon
TopicList icon
OutputValues icon
If icon
Set icon
Loop icon
Log icon
SlackIncomingWebhook icon
Pause icon
Fail icon
Schedule icon

Kafka Schema Registry Compatibility and Mode Drift Guard

Detect Schema Registry subjects off the governed compatibility level or mode, pin them back behind an approval gate, then re-read and fail if any still drifts.

Categories
DataInfrastructure

A Confluent Schema Registry decides whether a new schema version may be registered by checking the subject's compatibility level, then whether a producer may write at all by checking the subject's mode. Both are per subject settings with a global default. Both are changed by one HTTP call. Neither is reported anywhere once changed.

Set a subject to NONE for a one off migration then forget to put it back. Every breaking change after that is accepted in silence. The registry answers 200 with a new schema id, the producer ships, the consumers break on the next deploy. Leave a subject in READONLY or IMPORT after a restore. Every write is then refused with HTTP 422, which reads like a client bug rather than a registry setting. The registry serves both requests normally in both cases, so nothing raises it.

This blueprint reads both settings for every subject that backs a live Kafka topic, compares each against a baseline the operator declares as an input, pins the drifted ones back behind an approval gate, then re-reads both settings straight from the registry and fails the run if any targeted subject is still off baseline.

This blueprint was created by zkasuran.

Findings

Measured against Confluent Schema Registry 7.9.0 while building this.

  • With a subject at NONE the registry reports is_compatible: true for a schema that adds a mandatory field with no default, then registers it as a new version. With the same subject at BACKWARD the identical change is refused with HTTP 409 and READER_FIELD_MISSING_DEFAULT_VALUE. The level is the whole safety net.
  • GET /config/<subject> answers HTTP 404 with error_code 40408 when the subject carries no override, GET /mode/<subject> answers 404 with 40409. A 404 there means the subject inherits the global value. It is not a failure, which is why both reads allow a failed status and the code is part of the reading.
  • PUT /config/<subject> takes the key compatibility while GET returns compatibilityLevel. Reading back what you just wrote needs the other name.
  • Moving a subject out of READONLY or IMPORT back to READWRITE needs no force. Moving a subject that already holds schemas into IMPORT is refused with HTTP 422 and Cannot import since found existing subjects, so IMPORT is not a baseline a live registry can hold. A run that asks for it fails on the re-read rather than reporting a fix.
  • Changing the global level silently drifts every subject that inherits it. The remediation writes a subject level override instead of touching the global value, so the blast radius stays on the subjects that were audited.

Safety

  • Read-only by default. With the shipped inputs it lists subjects, reads both settings per subject, reports. It writes nothing.
  • A write needs three things at once: auto_remediate true, dry_run false, a human resuming the Pause with approved=true. An unanswered resume reads as not approved.
  • Only the setting that drifted is written. A subject off baseline on mode alone never gains a compatibility override it did not have before.
  • Subjects named in exempt_subjects are dropped before the audit, so a subject deliberately held at NONE for a migration is never read or written.
  • The global settings are never written. A drifted global shows up as drift on every inheriting subject.
  • Only subjects backing a live topic are audited. A subject whose topic is gone is left alone.
  • The proof is a fresh GET of both endpoints per targeted subject after the writes. A rejected write fails the run instead of being reported as fixed.

How it works

  1. list_subjects (io.kestra.plugin.core.http.Request) reads GET /subjects, the subjects the registry serves right now.
  2. list_topics (io.kestra.plugin.kafka.TopicList, read-only) returns the live topics on the cluster. That set is what scopes the audit.
  3. read_global_config and read_global_mode read GET /config plus GET /mode. globals pulls out the two inherited values so every subject is judged against the same pair.
  4. scope keeps the registered subjects that follow the <topic>-value or <topic>-key naming for a live non internal topic. The subjects input replaces that derivation when it is set. exempt_subjects is dropped either way.
  5. in_scope (io.kestra.plugin.core.flow.If) skips straight to a log line when nothing is in scope.
  6. audit (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) reads GET /config/<subject> plus GET /mode/<subject> for each subject, with a failed status allowed so a 404 is read as inherited. Each iteration folds its row into one KV accumulator, which is reset at the start of every run.
  7. assess applies the baseline once to the whole reading. A subject drifts when its effective value differs from the baseline. It also drifts when inherited_is_compliant is false and it carries no override. Compatibility and mode are judged separately, so one subject can drift on either or both.
  8. route branches on whether anything drifted.
    • Nothing drifted: all_clean logs that the net is on everywhere. No alert.
    • Something drifted: log_findings logs the per subject reading, including the global values so the cause is visible, then alert posts it to Slack.
      • remediate_gate runs only when auto_remediate is true and dry_run is false.
        • approve (io.kestra.plugin.core.flow.Pause) waits for a resume carrying approved.
        • When approved, remediate_pin (Loop, concurrencyLimit: 1) issues PUT /config/<subject> for a compatibility drift plus PUT /mode/<subject> for a mode drift, one subject at a time.
        • verify then re-reads both endpoints for every targeted subject into a second accumulator, verify_assess applies the same rule to that reading, verify_result fails the run through io.kestra.plugin.core.execution.Fail if any targeted subject is still off baseline.
        • When not approved, not_approved logs that nothing changed.
  9. errors posts to Slack on any failure, including an unreachable registry plus a write the registry refused.

What you get

  • A per subject reading of compatibility and mode, with inherited marked as inherited, against a baseline you declare.
  • Detection of the two silent failure modes: a safety net switched off, a subject that refuses writes.
  • A fix that is off by default, writes only the setting that drifted, touches only the subjects it reported.
  • Proof the fix landed: a fresh read of both endpoints that fails the run when a write was refused or reverted.

Who it's for

  • Platform teams who own a shared registry where one curl can turn off compatibility checking for a whole subject.
  • Anyone who has restored a registry from a backup then found producers failing with HTTP 422.
  • Teams that run compatibility as a governance rule rather than a per subject default, who need the rule checked on a schedule.

Why orchestrate this with Kestra

Reading two settings per subject is two HTTP calls. Doing it for every subject that backs a live topic, every day, against a declared baseline, is a pipeline. Kestra adds the schedule, the live topic set from the broker that scopes the audit, the Pause that turns a governance write into an approved change, the re-read that proves the write landed, the Slack alert, a history of every run you can point an auditor at.

Tested end to end

On Kestra 2.0.5 OSS against Apache Kafka 3.9.1 (KRaft, single node) with Confluent Schema Registry 7.9.0 and a local HTTP sink standing in for Slack. Topics orders, payments, shipments, with four subjects: orders-value (compatibility override), orders-key (no override at all), payments-value (mode override), shipments-value (an override equal to the baseline). Every state was confirmed with GET /config/<subject> plus GET /mode/<subject> straight against the registry before and after each run.

Run Inputs Result
1 defaults, orders-value at NONE, payments-value in READONLY 4 audited, 2 off baseline (1 compatibility, 1 mode). Nothing written
2 auto_remediate: true, dry_run: true Same report, no Pause, registry unchanged
3 auto_remediate: true, dry_run: false, resumed with approved=false Paused, declined, nothing written
4 auto_remediate: true, dry_run: false, approved orders-value NONE to BACKWARD, payments-value READONLY to READWRITE, re-read confirmed both. orders-key plus shipments-value untouched
5 defaults on the repaired registry 4 audited, 0 off baseline, all_clean, no alert
6 inherited_is_compliant: false 4 off baseline (2 compatibility, 3 mode), since every subject misses at least one override
7 orders-value back at NONE, exempt_subjects: ["orders-value"] 3 audited, 0 off baseline. The exempt subject was never read
8 baseline_mode: IMPORT, approved Every PUT /mode refused with HTTP 422. The re-read showed 4 of 4 still off baseline, the run FAILED, Slack alerted. The compatibility write in the same run did land
9 registry URL pointed at a closed port list_subjects failed, run FAILED, errors alerted
10 subjects: ["no-such-subject"] Nothing in scope, nothing read, nothing written
11 global level set to NONE, defaults 2 off baseline, both reported as inherited NONE. The two subjects with explicit BACKWARD overrides stayed clean
12 same, approved Both pinned with a subject level BACKWARD override. The global level was left at NONE

The detector was proven able to fail rather than only to pass: identical inputs gave 2 off baseline on the degraded registry (run 1) then 0 on the repaired one (run 5). The same registry gave 0 with the drifted subject exempt (run 7). The safety net itself was measured, not assumed: at NONE the registry accepted a schema adding a mandatory field with no default and bumped the subject to version 2, then after run 4 pinned it back to BACKWARD the same class of change was refused with HTTP 409.

Prerequisites

  • A Kafka cluster plus a Confluent Schema Registry reachable from the Kestra worker. The registry stores its state in a Kafka topic, so it cannot run without a broker. That is why this blueprint is not a demo.
  • A registry principal allowed to read /config plus /mode. Remediation also needs write on both. A broker principal allowed to list topics.
  • A registry that allows mode changes. PUT /mode/<subject> depends on the registry's mode.mutability setting, which the test registry for this blueprint was started with enabled.
  • Subjects named with the default TopicNameStrategy for the topic scoped audit. Any other naming can be audited by listing the subjects in the subjects input.

Secrets

  • SLACK_WEBHOOK_URL: used by alert and the errors block. In Kestra OSS, provide it as the base64 encoded environment variable SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
schema_registry_url http://localhost:8081 Registry base URL, no trailing slash
bootstrap_servers localhost:9092 Brokers, read the live topic set
baseline_compatibility BACKWARD The level every audited subject must resolve to
baseline_mode READWRITE The mode every audited subject must resolve to
inherited_is_compliant true False requires every audited subject to carry its own override
subjects [] Audit these subjects instead of deriving them from live topics
exempt_subjects [] Subjects allowed to sit off baseline
auto_remediate false Allow the write
dry_run true Must be false for a write to apply

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Import the flow, set schema_registry_url plus bootstrap_servers.
  3. Run it with the defaults. Read which subjects are off your baseline and which inherit it.
  4. Set baseline_compatibility plus baseline_mode to the rule your team actually governs by. List any deliberate exception in exempt_subjects.
  5. Run it with auto_remediate: true plus dry_run: false, then resume the paused execution with approved=true.
  6. Enable the daily_scan trigger, which always runs in safe mode.

How to extend

  • Set inherited_is_compliant to false to require an explicit override on every governed subject, so a later change to the global level cannot quietly weaken them.
  • Point the flow at a second registry by changing schema_registry_url, so one Kestra instance governs several clusters on one schedule.
  • Record each reading to a table before the gate, giving a drift history you can chart.
  • Add a Jira or ServiceNow task on the drift branch, so a NONE left behind by a migration opens a ticket as well as a Slack message.
  • Audit a non default subject naming strategy by filling the subjects input from your own inventory instead of letting live topics scope it.
  • Gate a CI deploy on this flow: fail the pipeline when any subject is off baseline, rather than fixing it.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.