<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://carlesarnal.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://carlesarnal.github.io/" rel="alternate" type="text/html" /><updated>2026-11-04T12:57:44+01:00</updated><id>https://carlesarnal.github.io/feed.xml</id><title type="html">Carles Arnal</title><subtitle>Principal Software Engineer at IBM, Apicurio Registry maintainer, Quarkus committer and educator. Distributed systems, data contracts and AI integration.</subtitle><author><name>Carles Arnal</name></author><entry><title type="html">Discovering Agents Is Only Half the Job: A Connected MCP and A2A Workflow</title><link href="https://carlesarnal.github.io/blog/agent-discovery/" rel="alternate" type="text/html" title="Discovering Agents Is Only Half the Job: A Connected MCP and A2A Workflow" /><published>2026-10-23T00:00:00+02:00</published><updated>2026-10-23T00:00:00+02:00</updated><id>https://carlesarnal.github.io/blog/agent-discovery</id><content type="html" xml:base="https://carlesarnal.github.io/blog/agent-discovery/"><![CDATA[<p>An endpoint tells you where to send a request. It does not tell you whether the service still offers the capability your application expects. That distinction becomes important when agents and tools are published independently: discovery needs useful metadata, and changes to that metadata need a reviewable lifecycle.</p>

<p>Let’s make this concrete with a small Quarkus application. An MCP weather tool returns data, an A2A summarizer turns it into a briefing, and an A2A translator translates that briefing. Apicurio Registry supplies the endpoint metadata. Application code supplies the sequence.</p>

<h2 id="two-paths-through-the-system">Two paths through the system</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Metadata:   publishers -&gt; Apicurio Registry -&gt; application discovery

Execution:  application -&gt; MCP weather -&gt; A2A summary -&gt; A2A translation
</code></pre></div></div>

<p>The Registry is not forwarding the weather result or choosing the next agent. The application asks for the required roles, resolves their endpoints and passes each actual result to the next step. This is deliberately a fixed workflow: its value is making the integration boundaries visible, not demonstrating autonomous planning.</p>

<p>The weather is fictional fixture data. The protocol calls, Registry requests and model invocations execute for real. That combination lets us inspect the plumbing without relying on a live weather provider.</p>

<h2 id="run-the-connected-example">Run the connected example</h2>

<p>The companion repository wraps a pinned Quarkus LangChain4j sample. You need Git, Python 3.9+, Docker Compose, Java 25 to match the recorded environment, and a running host Ollama instance.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span> talk/evidence-2026-10-23 <span class="se">\</span>
  https://github.com/carlesarnal/agent-discovery-demo.git
<span class="nb">cd </span>agent-discovery-demo
ollama pull qwen2.5:7b
python3 scripts/workflow.py prepare
bash scripts/paris-2026-demo.sh
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">http://localhost:11020/</code>. The dashboard shows the briefing stages and the Registry versions observed before execution. In another terminal, run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 scripts/workflow.py governance
</code></pre></div></div>

<p>The wrapper fetches commit <code class="language-plaintext highlighter-rouge">682928b714574e1ca8f0405c4319b36cd244dc0e</code> from the sample associated with <a href="https://github.com/quarkiverse/quarkus-langchain4j/pull/2932">PR #2932</a>. That PR was open when the example was checked. The pin is important: the sample builds its extensions from source, so it is not claiming that this workflow is available in every released extension version.</p>

<h2 id="remove-a-capability-and-inspect-the-result">Remove a capability and inspect the result</h2>

<p>The governance sequence starts with a successful briefing. It then attempts to publish a translator Agent Card with the translation skill removed.</p>

<p>Under the configured compatibility rule, Registry rejects the update. A useful test does more than assert an error response:</p>

<table>
  <thead>
    <tr>
      <th>Assertion</th>
      <th>Why it matters</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The rejection identifies the removed skill</td>
      <td>An unrelated validation error would not prove the intended rule ran</td>
    </tr>
    <tr>
      <td>The proposed version is absent</td>
      <td>The failed request must not leave an accepted new contract</td>
    </tr>
    <tr>
      <td>Accepted content, version and global ID are unchanged</td>
      <td>Existing discovery metadata must remain available</td>
    </tr>
    <tr>
      <td>The complete briefing still runs</td>
      <td>The accepted configuration is still usable by this application</td>
    </tr>
  </tbody>
</table>

<p>A compatible addition follows the rejection. It creates a registered version and the application runs the briefing again. Testing both paths avoids turning governance into a demonstration where every update is simply forbidden.</p>

<h2 id="fresh-metadata-needs-correct-publication">Fresh metadata needs correct publication</h2>

<p>There is another failure mode before compatibility checking even begins: an agent restarts with a changed card but the publisher leaves the old card in Registry.</p>

<p>I addressed that in <a href="https://github.com/quarkiverse/quarkus-langchain4j/pull/2897">Quarkus LangChain4j PR #2897</a>, merged on September 30, 2026. The change uses find-or-create version behavior, handles changed card content when the declared agent version is unchanged, and updates artifact labels. It includes a regression test against Registry.</p>

<p>Publication freshness and compatibility are separate responsibilities. A compatibility rule cannot inspect a change that was never submitted. Conversely, publishing current metadata does not establish that the remote implementation behaves correctly.</p>

<h2 id="where-the-guarantees-stop">Where the guarantees stop</h2>

<p>The successful rerun uses an unchanged running translator after a rejected metadata update. It does <strong>not</strong> prove that Registry prevents someone from deploying incompatible translator code. Connecting those controls requires deployment and behavioral tests owned by the application team.</p>

<p>Similarly, the versions displayed by the dashboard are observations, not transactional deployment pins. The local example has no deprecation-routing gate and no production authentication setup. The MCP registration is explicit in the runner, while A2A publication uses the extension’s publisher.</p>

<table>
  <thead>
    <tr>
      <th>Responsibility</th>
      <th>Owner in this example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Describe and publish capabilities</td>
      <td>Agent/tool publisher</td>
    </tr>
    <tr>
      <td>Store versions and check publication</td>
      <td>Apicurio Registry</td>
    </tr>
    <tr>
      <td>Select roles and sequence the work</td>
      <td>Application code</td>
    </tr>
    <tr>
      <td>Verify handoffs and remote behavior</td>
      <td>Integration tests</td>
    </tr>
    <tr>
      <td>Control which implementation is deployed</td>
      <td>Deployment process</td>
    </tr>
  </tbody>
</table>

<h2 id="when-a-registry-earns-its-place">When a registry earns its place</h2>

<p>If three services are released together by one team, configured URLs may be enough. A Registry becomes more useful when teams publish independently and callers need a shared place to inspect capabilities and their evolution.</p>

<p>The <a href="https://github.com/carlesarnal/agent-discovery-demo/tree/talk/evidence-2026-10-23">implementation</a> includes the interactive flow and separate <code class="language-plaintext highlighter-rouge">test</code> and <code class="language-plaintext highlighter-rouge">smoke</code> commands. Stop the interactive runner before starting the smoke test; it creates its own services. The goal is not to remove application responsibility. It is to make the responsibility for discovery, publication and execution explicit enough to test.</p>]]></content><author><name>carles</name></author><category term="Quarkus" /><category term="A2A" /><category term="MCP" /><category term="Registry" /><summary type="html"><![CDATA[A connected Quarkus workflow separates endpoint discovery, Agent Card publication and runtime behavior.]]></summary></entry><entry><title type="html">Before and After the Sandbox Vote: Preparing an Open-Source Project for CNCF</title><link href="https://carlesarnal.github.io/blog/cncf-sandbox-journey/" rel="alternate" type="text/html" title="Before and After the Sandbox Vote: Preparing an Open-Source Project for CNCF" /><published>2026-10-23T00:00:00+02:00</published><updated>2026-10-23T00:00:00+02:00</updated><id>https://carlesarnal.github.io/blog/cncf-sandbox-journey</id><content type="html" xml:base="https://carlesarnal.github.io/blog/cncf-sandbox-journey/"><![CDATA[<p>In January 2024, I closed Apicurio Registry’s earlier CNCF Sandbox application. We had branding questions to resolve and were exploring our relationship with the xRegistry effort. In March 2026, the renewed application passed its Technical Oversight Committee vote.</p>

<p>Between those two moments was work that is easy to leave out of an acceptance announcement: defining the project boundary, explaining its relationship to neighboring work, and assigning responsibility for what happens after the vote. This post uses the public application record to make that work useful to other maintainers.</p>

<h2 id="an-application-is-a-description-that-other-people-must-evaluate">An application is a description that other people must evaluate</h2>

<p>Maintainers already know what their software does. People reviewing a contribution to a foundation do not share all of that context. They need to understand which repositories are involved, how decisions are made and why the project fits the ecosystem.</p>

<p>The <a href="https://github.com/cncf/sandbox/issues/72">earlier application</a> contains a good example. A question about using Apicurio with the Kubernetes API required clarification: deploying Registry through an operator, managing Registry artifacts through Kubernetes, and using Registry to describe another API are different integrations.</p>

<p>The lesson is not that every application must implement all of them. It is that an answer like “we integrate with Kubernetes” is too broad to tell a reader which capability actually exists.</p>

<h2 id="defining-the-boundary-came-before-continuing">Defining the boundary came before continuing</h2>

<p>My <a href="https://github.com/cncf/sandbox/issues/72#issuecomment-1906355967">January 23, 2024 comment</a> recorded the decision to defer over branding questions and engagement with the Discovery API/xRegistry effort. It was a decision to clarify the contribution, not evidence that the software had stopped being useful.</p>

<p>The <a href="https://github.com/cncf/sandbox/issues/461">renewed application</a> explicitly identifies the Registry repository in scope and describes the consolidation of Studio functionality. It also explains the intended relationship between a registry implementation and the xRegistry specification.</p>

<p>That distinction matters in an application just as it does in technical documentation. Implementing a specification, intending to implement it and participating in its community are related but different claims. A reviewer should be able to tell which one the project is making and what evidence supports it.</p>

<h2 id="the-vote-and-onboarding-are-separate-milestones">The vote and onboarding are separate milestones</h2>

<p>The renewed application’s vote passed on March 12, 2026. A subsequent message required the contribution agreement before official onboarding could begin. The <a href="https://github.com/cncf/sandbox/issues/495">onboarding issue</a> then listed work spanning project and foundation responsibilities.</p>

<p>Examples include licensing and trademark arrangements, repository hosting, governance and security documentation, community channels and project services. These are not all engineering tasks, and they are not all tasks a maintainer can complete alone.</p>

<p>It is tempting to compress that sequence into “the project was accepted.” For planning, the distinction is more useful:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Define the contribution
        ↓
Substantiate the application
        ↓
Review and vote
        ↓
Agreements and onboarding
        ↓
Ongoing project stewardship
</code></pre></div></div>

<p>The public record establishes that these steps occurred. It does not establish that one feature caused acceptance, or that I personally completed every onboarding task. Those would be different claims requiring different evidence.</p>

<h2 id="turn-broad-claims-into-a-worksheet">Turn broad claims into a worksheet</h2>

<p>The <a href="https://github.com/carlesarnal/cncf-sandbox-journey/blob/main/worksheet.md">companion worksheet</a> uses a simple structure:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>What to write</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Question</td>
      <td>What does the application or reviewer need to know?</td>
    </tr>
    <tr>
      <td>Claim</td>
      <td>What are we saying about the project?</td>
    </tr>
    <tr>
      <td>Public evidence</td>
      <td>A document, code path or decision record, with its revision/date</td>
    </tr>
    <tr>
      <td>Remaining gap</td>
      <td>What is missing or still ambiguous?</td>
    </tr>
    <tr>
      <td>Owner</td>
      <td>Who will take the next action?</td>
    </tr>
  </tbody>
</table>

<p>For example, “we have open governance” should lead to a public decision process, named responsibilities and a path for an outside contributor to participate. “We fit this standard” should identify implemented behavior or explicitly say that alignment is planned.</p>

<p>Evidence should also be maintainable. A link to a changing <code class="language-plaintext highlighter-rouge">main</code> branch may be sufficient for exploration, but a historical account benefits from a dated reference. Record what the evidence established at the time instead of treating the current state of a repository as a perfect reconstruction of the past.</p>

<h2 id="carry-ownership-beyond-acceptance">Carry ownership beyond acceptance</h2>

<p>Foundation participation does not remove the daily work of reviewing changes, maintaining releases or responding to users. An application can reveal gaps that remain relevant long after admission: unclear ownership, contribution instructions that assume internal knowledge, or security documentation nobody has responsibility to update.</p>

<p>Use the same worksheet after the milestone. Which promises require continuing work? Which documents need periodic review? Which services now have a foundation contact as well as a project owner?</p>

<p>There is no universal formula here. Requirements evolve, and another project’s situation may differ from Apicurio’s. Check the <a href="https://github.com/cncf/toc/tree/main/process">current CNCF project process</a> before applying. The reusable practice is to connect each claim to something another person can inspect and each gap to someone who can act on it.</p>

<h2 id="share-the-decisions-not-only-the-announcement">Share the decisions, not only the announcement</h2>

<p>The <a href="https://www.apicur.io/blog/2026/06/18/apicurio-registry-joins-cncf">acceptance announcement</a> records an important milestone for Apicurio Registry. The earlier deferral and onboarding record provide a different kind of value: they show the questions a maintainer team needed to resolve.</p>

<p>If you are preparing your own project, begin there. Define the contribution, distinguish current capability from intent, and make the remaining work visible. That produces a clearer application—and a clearer project for contributors and users, regardless of the outcome of a foundation review.</p>]]></content><author><name>carles</name></author><category term="CNCF" /><category term="Maintainers" /><category term="Governance" /><summary type="html"><![CDATA[The public deferral, renewed application and onboarding records behind Apicurio Registry's Sandbox journey.]]></summary></entry><entry><title type="html">One Keycloak Realm, Two Permission Models: Kafka and Apicurio Registry</title><link href="https://carlesarnal.github.io/blog/identity-boundaries/" rel="alternate" type="text/html" title="One Keycloak Realm, Two Permission Models: Kafka and Apicurio Registry" /><published>2026-10-23T00:00:00+02:00</published><updated>2026-10-23T00:00:00+02:00</updated><id>https://carlesarnal.github.io/blog/identity-boundaries</id><content type="html" xml:base="https://carlesarnal.github.io/blog/identity-boundaries/"><![CDATA[<p>An application can have permission to publish Kafka records without having permission to change their schemas. That sounds straightforward, but connecting both Kafka and Apicurio Registry to the same identity provider can obscure where those two decisions happen.</p>

<p>In this post, we’ll connect Keycloak, Kafka’s Strimzi OAuth libraries, and Apicurio Registry, then test the boundaries. We’ll use successful requests as controls, deliberately deny operations, and inspect the resulting state. Along the way, we’ll see why acquiring a token is not enough to prove that authentication is enabled.</p>

<h2 id="two-requests-two-decisions">Two requests, two decisions</h2>

<p>A Registry-aware Kafka producer has two separate connections to consider:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                      Keycloak
                      /      \
             access token    access token
                    /          \
          Schema lookup         Kafka produce
                 |                    |
        Apicurio Registry         Kafka broker
        token checks + RBAC       token checks + ACLs
</code></pre></div></div>

<p>Registry evaluates access to groups, artifacts and versions. Kafka evaluates operations on topics, consumer groups and other broker resources. Sharing a realm does not combine these permission models, and it does not make an access token appropriate for every service.</p>

<p>Our example uses two service accounts, <code class="language-plaintext highlighter-rouge">publisher</code> and <code class="language-plaintext highlighter-rouge">reader</code>. Registry maps their realm roles to <code class="language-plaintext highlighter-rouge">sr-admin</code> and <code class="language-plaintext highlighter-rouge">sr-readonly</code>. Kafka separately grants topic write access to <code class="language-plaintext highlighter-rouge">service-account-publisher</code>. The administrator role is convenient for preparing the local example; a production schema publisher should receive only the permissions it actually needs.</p>

<h2 id="run-the-example">Run the example</h2>

<p>You need Python 3 and Docker with Compose. The example starts Keycloak 26.0, Registry 3.3.3, and Kafka 3.9.0 from the Strimzi 0.45.0 image.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span> talk/evidence-2026-10-23 <span class="se">\</span>
  https://github.com/carlesarnal/keycloak-strimzi-apicurio-security.git
<span class="nb">cd </span>keycloak-strimzi-apicurio-security
python3 verify.py
</code></pre></div></div>

<p>The runner starts its own Compose project, performs the checks and removes the project’s containers afterward. Its fixed credentials, plaintext connections and internal administrative broker listener are for this disposable local environment. The administrative listener is not exposed as a host port; it must not become an alternative route for production clients to bypass OAuth.</p>

<h2 id="enable-the-registry-oidc-tenant">Enable the Registry OIDC tenant</h2>

<p>The important Registry settings in this example are:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">environment</span><span class="pi">:</span>
  <span class="na">QUARKUS_OIDC_TENANT_ENABLED</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
  <span class="na">QUARKUS_OIDC_AUTH_SERVER_URL</span><span class="pi">:</span> <span class="s">http://keycloak:8080/realms/lab</span>
  <span class="na">QUARKUS_OIDC_CLIENT_ID</span><span class="pi">:</span> <span class="s">registry</span>
  <span class="na">QUARKUS_OIDC_TOKEN_ISSUER</span><span class="pi">:</span> <span class="s">http://localhost:18230/realms/lab</span>
  <span class="na">QUARKUS_OIDC_TOKEN_AUDIENCE</span><span class="pi">:</span> <span class="s">registry</span>
  <span class="na">APICURIO_AUTH_ROLE_BASED_AUTHORIZATION</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
</code></pre></div></div>

<p>The addresses differ for a reason. Registry discovers keys through the Compose-network address, while the host-side client obtains its token through <code class="language-plaintext highlighter-rouge">localhost:18230</code>. The issuer check must match the issuer in that token. The Keycloak clients also have an audience mapper that includes <code class="language-plaintext highlighter-rouge">registry</code> in their access tokens.</p>

<p>An earlier configuration used <code class="language-plaintext highlighter-rouge">APICURIO_AUTH_ENABLED</code>. In this Registry 3.3.3 setup, that did not enable the OIDC tenant: a request carrying an invalid token still returned <code class="language-plaintext highlighter-rouge">200</code>. Setting <code class="language-plaintext highlighter-rouge">QUARKUS_OIDC_TENANT_ENABLED</code> corrected the configuration, and the same negative check returned <code class="language-plaintext highlighter-rouge">401</code>.</p>

<p>That is why the test begins with an invalid credential, rather than simply demonstrating that a valid credential works. A successful authenticated-looking request can also succeed when authentication is disabled.</p>

<h2 id="verify-read-access-without-granting-mutation-access">Verify read access without granting mutation access</h2>

<p>After the publisher creates an artifact, the reader fetches its version content. The reader then attempts to create another version.</p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Identity</th>
      <th>Observed result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>List groups</td>
      <td>Invalid token</td>
      <td><code class="language-plaintext highlighter-rouge">401</code></td>
    </tr>
    <tr>
      <td>Fetch version 1</td>
      <td>Reader</td>
      <td><code class="language-plaintext highlighter-rouge">200</code></td>
    </tr>
    <tr>
      <td>Publish version 2</td>
      <td>Reader</td>
      <td><code class="language-plaintext highlighter-rouge">403</code></td>
    </tr>
    <tr>
      <td>Fetch version 1 again</td>
      <td>Reader</td>
      <td>Original content</td>
    </tr>
    <tr>
      <td>Look up version 2</td>
      <td>Administrator</td>
      <td><code class="language-plaintext highlighter-rouge">404</code></td>
    </tr>
  </tbody>
</table>

<p>The last two checks are important. They establish the resource outcome, not just the response code: the accepted version remains unchanged and the denied version does not exist.</p>

<p>These checks also separate three diagnoses. A <code class="language-plaintext highlighter-rouge">401</code> points toward authentication. A <code class="language-plaintext highlighter-rouge">403</code> with a valid identity points toward authorization. A successful lookup followed by a denied mutation shows that the resource and connection are working while the operation is forbidden.</p>

<h2 id="grant-kafka-access-independently">Grant Kafka access independently</h2>

<p>The broker validates tokens through Strimzi’s OAuth callbacks and maps <code class="language-plaintext highlighter-rouge">preferred_username</code> to the Kafka principal. The example grants these operations:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kafka-acls.sh <span class="nt">--bootstrap-server</span> kafka:9092 <span class="se">\</span>
  <span class="nt">--add</span> <span class="nt">--allow-principal</span> User:service-account-publisher <span class="se">\</span>
  <span class="nt">--operation</span> Write <span class="nt">--operation</span> Describe <span class="nt">--topic</span> orders
</code></pre></div></div>

<p>The runner executes this command inside the disposable broker container using the internal administration listener. Application clients use the OAuth listener instead.</p>

<p>The publisher’s record arrives in <code class="language-plaintext highlighter-rouge">orders</code>. The reader obtains a valid token but receives <code class="language-plaintext highlighter-rouge">TopicAuthorizationException</code> when attempting to produce. A broker read then confirms that only the publisher’s record is present. A console producer can log an asynchronous send failure without making its process exit status a sufficient assertion, so checking the actual record matters.</p>

<h2 id="apply-the-diagnostic-method-to-your-deployment">Apply the diagnostic method to your deployment</h2>

<p>When an application fails to access a Kafka platform, follow the specific connection before changing permissions:</p>

<ol>
  <li>Identify whether the failure came from the broker or the serializer’s Registry request.</li>
  <li>Check the intended issuer, audience and mapped principal at that service.</li>
  <li>Compare the failing operation with a successful control using the same path.</li>
  <li>Inspect the smallest relevant permission: a Kafka ACL and a Registry role are different controls.</li>
  <li>Verify the resulting state, especially after a denied write.</li>
</ol>

<p>The Compose example exercises the real token and authorization machinery. Kubernetes networking, TLS, token renewal and signing-key rotation add further deployment concerns; they do not change the need to test these boundaries separately.</p>

<p>The complete configuration and assertions are in the <a href="https://github.com/carlesarnal/keycloak-strimzi-apicurio-security/tree/talk/evidence-2026-10-23">example repository</a>. For a different enforcement model, our earlier <a href="https://www.apicur.io/blog/2026/01/28/registry-external-auth">Envoy and OPA article</a> moves authentication and authorization to a proxy. In either design, the useful question is the same: which component made this decision, and what evidence shows that it enforced it?</p>]]></content><author><name>carles</name></author><category term="OIDC" /><category term="Kafka" /><category term="Security" /><summary type="html"><![CDATA[Real token and authorization checks across Keycloak, Kafka and Registry, including the negative control that exposed disabled authentication.]]></summary></entry><entry><title type="html">Validating Before Kafka: What a Schema-Checking Proxy Changes for Producers</title><link href="https://carlesarnal.github.io/blog/kafka-proxy-validation/" rel="alternate" type="text/html" title="Validating Before Kafka: What a Schema-Checking Proxy Changes for Producers" /><published>2026-10-23T00:00:00+02:00</published><updated>2026-10-23T00:00:00+02:00</updated><id>https://carlesarnal.github.io/blog/kafka-proxy-validation</id><content type="html" xml:base="https://carlesarnal.github.io/blog/kafka-proxy-validation/"><![CDATA[<p>Client-side schema validation is useful until a producer does not use it. A legacy application, a third-party integration or a configuration mistake can send data that consumers cannot interpret. Putting validation between the producer and the broker gives us another enforcement point—but it also creates a new failure contract for producers.</p>

<p>I contributed Avro and Protobuf support to the <a href="https://github.com/kroxylicious/kroxylicious/pull/3498">Kroxylicious Record Validation filter</a>. In this article, we’ll use its JSON Schema support with Apicurio Registry to examine that failure contract. We’ll reject invalid records, inspect a mixed batch, bypass the proxy, stop Registry and recover the write path.</p>

<h2 id="where-validation-happens">Where validation happens</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Producer ──produce request──&gt; Kroxylicious ──accepted records──&gt; Kafka
                                   |
                                   └──schema lookup──&gt; Apicurio Registry
</code></pre></div></div>

<p>The producer still speaks the Kafka protocol. Kroxylicious inspects record values before forwarding them, and Registry supplies the schema identified by the proxy configuration.</p>

<p>This example uses Kroxylicious <strong>0.24.0</strong>, Registry <strong>3.3.3</strong> and Kafka <strong>3.9.1</strong>. The Record Validation filter is a Preview feature in the referenced implementation; use the versioned behavior shown here rather than assuming identical error or caching behavior across releases.</p>

<h2 id="try-the-complete-sequence">Try the complete sequence</h2>

<p>You need Python 3 and Docker Compose:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span> talk/evidence-2026-10-23 <span class="se">\</span>
  https://github.com/carlesarnal/kroxylicious-schema-validation.git
<span class="nb">cd </span>kroxylicious-schema-validation
python3 talk/demo.py
</code></pre></div></div>

<p>The runner starts a dedicated Compose project, registers the schema, generates the proxy configuration and runs six cases. It reads the broker after the important writes and removes its containers when finished. Producer output is saved under <code class="language-plaintext highlighter-rouge">talk/target/producer-results.json</code>.</p>

<h2 id="register-a-deliberately-small-contract">Register a deliberately small contract</h2>

<p>The order schema requires an identifier and a nonnegative amount:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"object"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"required"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"orderId"</span><span class="p">,</span><span class="w"> </span><span class="s2">"amount"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"orderId"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">},</span><span class="w">
    </span><span class="nl">"amount"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"number"</span><span class="p">,</span><span class="w"> </span><span class="nl">"minimum"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Registry returns a <code class="language-plaintext highlighter-rouge">contentId</code> when this artifact is created. The runner substitutes that value into the filter configuration:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">filterDefinitions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">order-validation</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">RecordValidation</span>
    <span class="na">config</span><span class="pi">:</span>
      <span class="na">rules</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">topicNames</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">orders</span><span class="pi">]</span>
          <span class="na">valueRule</span><span class="pi">:</span>
            <span class="na">schemaValidationConfig</span><span class="pi">:</span>
              <span class="na">apicurioRegistryUrl</span><span class="pi">:</span> <span class="s">http://registry:8080</span>
              <span class="na">apicurioId</span><span class="pi">:</span> <span class="s">__ID__</span>
              <span class="na">schemaType</span><span class="pi">:</span> <span class="s">JSON_SCHEMA</span>
<span class="na">defaultFilters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">order-validation</span><span class="pi">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">__ID__</code> is a generation placeholder, not a value to send to the proxy. This example pins the filter to registered content; it is not a demonstration of automatically selecting the latest schema version.</p>

<h2 id="one-bad-record-can-affect-good-records">One bad record can affect good records</h2>

<p>A valid order reaches Kafka. An order with a negative amount produces <code class="language-plaintext highlighter-rouge">InvalidRecordException</code> and is absent from the topic.</p>

<p>Next, the runner submits a valid order and an invalid order in a batch, using a single-partition topic and producer settings that allow them to batch together. In the tested setup, <strong>both records are rejected</strong>. Reading Kafka confirms that the valid member of the failed batch did not arrive either.</p>

<p>This matters for application error handling. “Retry the valid record” may be the right next step for this known rejected batch, but it is not a complete general recovery algorithm. Producers must account for ordering requirements, ambiguous outcomes and duplicate handling. A broker timeout, for example, must not automatically be interpreted as the same definite rejection demonstrated by this test.</p>

<h2 id="a-proxy-only-protects-its-path">A proxy only protects its path</h2>

<p>The next case sends an invalid record directly to Kafka’s internal broker endpoint. Kafka stores it because that request did not pass through the validation filter.</p>

<p>There is no contradiction: the broker has not become schema-aware merely because a proxy exists. If proxy validation is a required control, broker access and network policy must enforce the path. Keep client-side validation too when possible; it can give the application faster, more contextual feedback before a network call.</p>

<h2 id="a-registry-outage-looks-different-from-invalid-input">A Registry outage looks different from invalid input</h2>

<p>Stopping Registry creates a more subtle case. We send a valid order through the proxy and receive an <code class="language-plaintext highlighter-rouge">InvalidRecordException</code> containing:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>context=SCHEMA_RESOLUTION_ERROR
</code></pre></div></div>

<p>The record is rejected because its schema cannot be resolved, not because its amount violates the schema. The exception class alone therefore cannot decide whether an application should correct its input or investigate an infrastructure dependency.</p>

<table>
  <thead>
    <tr>
      <th>Case</th>
      <th>Producer observation</th>
      <th>Broker observation</th>
      <th>First action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Valid order</td>
      <td>No validation rejection</td>
      <td>Record present</td>
      <td>Normal acknowledgement handling</td>
    </tr>
    <tr>
      <td>Invalid amount</td>
      <td>Constraint failure</td>
      <td>Record absent</td>
      <td>Correct or quarantine input</td>
    </tr>
    <tr>
      <td>Mixed batch</td>
      <td>Batch rejection</td>
      <td>Both new records absent</td>
      <td>Evaluate batch-level recovery</td>
    </tr>
    <tr>
      <td>Direct broker bypass</td>
      <td>No proxy check</td>
      <td>Invalid record present</td>
      <td>Fix the enforcement path</td>
    </tr>
    <tr>
      <td>Registry unavailable</td>
      <td>Schema resolution error</td>
      <td>Valid record absent</td>
      <td>Restore schema availability</td>
    </tr>
  </tbody>
</table>

<h2 id="recovery-means-restoring-the-schema-too">Recovery means restoring the schema, too</h2>

<p>The recovery test initially failed even after the Registry container came back. Our local Registry uses ephemeral storage: restarting the process restored HTTP availability but lost the schema. The proxy then reported <code class="language-plaintext highlighter-rouge">ContentNotFoundException</code> for its configured content ID.</p>

<p>The corrected laboratory sequence re-registers the schema and checks that the restored <code class="language-plaintext highlighter-rouge">contentId</code> matches the proxy’s configuration before sending a new record. That record reaches Kafka. In a durable deployment, the corresponding responsibility is preserving and restoring Registry data, not hoping that a healthy process has the same content as before.</p>

<p>This recovery is intentionally narrow. It demonstrates restoring the dependency and successfully sending a new record. It does not claim a general exactly-once resend strategy.</p>

<h2 id="design-the-failure-contract-before-enabling-enforcement">Design the failure contract before enabling enforcement</h2>

<p>The proxy adds useful protection, particularly when producers cannot all be changed together. Its operational cost is that schema resolution, batching and routing now participate in write behavior.</p>

<p>Before enabling it, decide how producers distinguish constraint errors from resolution errors, how failed batches are handled, and how direct access is controlled. The <a href="https://github.com/carlesarnal/kroxylicious-schema-validation/tree/talk/evidence-2026-10-23">runnable example</a> turns those questions into assertions against actual broker contents. That is the useful test of an enforcement point: not only whether it reports an error, but what it allows to be stored.</p>]]></content><author><name>carles</name></author><category term="Kafka" /><category term="Kroxylicious" /><category term="Schemas" /><summary type="html"><![CDATA[What mixed-batch rejection, direct broker access and Registry outages mean for a Kafka producer's failure handling.]]></summary></entry><entry><title type="html">Who Owes the Next Action? Making Pull Request Hand-offs Explicit</title><link href="https://carlesarnal.github.io/blog/maintainer-handoffs/" rel="alternate" type="text/html" title="Who Owes the Next Action? Making Pull Request Hand-offs Explicit" /><published>2026-10-23T00:00:00+02:00</published><updated>2026-10-23T00:00:00+02:00</updated><id>https://carlesarnal.github.io/blog/maintainer-handoffs</id><content type="html" xml:base="https://carlesarnal.github.io/blog/maintainer-handoffs/"><![CDATA[<p>A pull request has been quiet for two weeks. Is the author inactive, or are they waiting for a maintainer? A generic stale message cannot answer that question, but it can easily ask the wrong person to act.</p>

<p>Maintaining an open-source project involves more than deciding whether a patch is correct. Contributors need to know who owns the next step, what evidence is missing and what happens when that person is unavailable. The project’s limited review capacity needs to be visible too.</p>

<p>Apicurio Registry’s published PR lifecycle provides a concrete example of making those hand-offs explicit. Let’s look at its state model and one public review, then extract the parts another project can use without adopting the same automation.</p>

<h2 id="inactivity-needs-an-owner">Inactivity needs an owner</h2>

<p>The <a href="https://github.com/Apicurio/apicurio-registry/blob/main/.github/PR_LIFECYCLE.md">documented lifecycle</a> distinguishes <code class="language-plaintext highlighter-rouge">waiting-on-author</code> from <code class="language-plaintext highlighter-rouge">waiting-on-maintainer</code>. That difference affects escalation: a PR blocked only on a maintainer does not become stale and get automatically closed for contributor inactivity. Instead, it can receive a <code class="language-plaintext highlighter-rouge">review-overdue</code> signal.</p>

<p>That policy asks a better question than “when was this PR last updated?” Bots and CI can update a PR without a human moving the review forward. Conversely, an author may have nothing useful to add while waiting for an assigned reviewer.</p>

<p>The documented process also has an explicit trade-off: if both the author and maintainer are blocked, the contributor timer takes precedence. An ownership-aware process still needs decisions about ambiguous states; it does not make them disappear.</p>

<h2 id="separate-the-hand-offs">Separate the hand-offs</h2>

<p>Here is a simplified view of the responsibilities:</p>

<table>
  <thead>
    <tr>
      <th>State</th>
      <th>Next owner</th>
      <th>Evidence or action needed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ready for review</td>
      <td>Maintainer</td>
      <td>Scope, test evidence and an available reviewer</td>
    </tr>
    <tr>
      <td>Changes requested</td>
      <td>Contributor</td>
      <td>A specific actionable request</td>
    </tr>
    <tr>
      <td>Approved, verification pending</td>
      <td>CI and maintainer</td>
      <td>Valid approval and checks for the current commit</td>
    </tr>
    <tr>
      <td>Checks failed</td>
      <td>Assigned investigator</td>
      <td>Diagnosis of patch failure versus infrastructure failure</td>
    </tr>
    <tr>
      <td>Waiting on maintainer</td>
      <td>Maintainer</td>
      <td>Review, reassignment or escalation</td>
    </tr>
    <tr>
      <td>Both sides blocked</td>
      <td>Explicit policy</td>
      <td>Identify which action unblocks the other</td>
    </tr>
  </tbody>
</table>

<p>The table is not a replacement for the actual repository rules. It is a way to inspect whether every state has a meaningful next action.</p>

<p>For instance, a suggested reviewer is not proof that the person has accepted ownership. A successful quick check is not a successful full verification suite. “Approved” is not necessarily “ready to merge now” if required checks are still running or a newer commit invalidated the review.</p>

<h2 id="a-real-approval-to-verification-sequence">A real approval-to-verification sequence</h2>

<p><a href="https://github.com/Apicurio/apicurio-registry/pull/10409">PR #10409</a> fixed a pagination test that could include agent records created by other tests. The change scoped fixtures with a unique label and checked that the combined pages contained the expected agents, rather than assuming an ordering not guaranteed by the API.</p>

<p>The public record shows a reviewer suggestion, my approval on October 7, 2026, and bot comments distinguishing the passed quick check from the remaining full-suite gate. The PR merged later that day.</p>

<p>This is useful because each transition is inspectable. It does not prove that automation reduced review time or increased contributor retention. It shows how the contributor and maintainer can tell which decision or check remains outstanding.</p>

<p>It also illustrates why source changes and workflow state need different explanations. A contributor should understand the technical fix from the PR description and the next operational step from the lifecycle feedback, without reverse-engineering several CI dashboards.</p>

<h2 id="assignment-is-a-policy-not-just-a-notification">Assignment is a policy, not just a notification</h2>

<p>The documented reviewer-assignment approach considers ownership, interest and load. These signals represent different things: experience with the changed code, willingness to review an area and recent assignment pressure.</p>

<p>None is a perfect measure of availability. A specialist may appear lightly loaded because they cover fewer areas, not because they have spare capacity. A person who wrote much of the code may become a bottleneck if ownership dominates every assignment.</p>

<p>This is why an assignment system needs an explanation and a manual correction path. Contributors need a person to act; maintainers need to understand why that person was selected and how to change the policy when its assumptions stop fitting the team.</p>

<h2 id="start-with-the-convention-before-the-bot">Start with the convention before the bot</h2>

<p>You can use the <a href="https://github.com/carlesarnal/oss-maintainer-playbook/blob/main/handoff.md">hand-off worksheet</a> without deploying any automation. Pick one delayed contribution and answer:</p>

<ol>
  <li>Who owns the next action?</li>
  <li>What specific evidence or decision is required?</li>
  <li>How can the contributor see that state?</li>
  <li>Who can take over if the owner is unavailable?</li>
  <li>What event makes the current status obsolete?</li>
</ol>

<p>If these answers are unclear, automating a reminder will not clarify them. First agree on a small convention; then automate the repetitive parts that the team can realistically maintain.</p>

<p>Automation itself becomes project code. Labels can be stale, commands can race with new commits and a workflow can report the wrong state. Keep the authoritative checks tied to the relevant commit and expose enough context for people to recover from a mistaken automated message.</p>

<h2 id="measure-what-the-process-actually-changes">Measure what the process actually changes</h2>

<p>Review waiting time and repeat participation are useful signals to investigate. They are not interchangeable with causality. A change in contribution volume, release timing or team availability can affect those numbers independently of a new lifecycle policy.</p>

<p>Start with a concrete outcome: fewer PRs where nobody knows the owner, fewer author-directed reminders while a maintainer is blocking progress, or a clearer path from approval to required verification. Then inspect whether the policy produces that outcome.</p>

<p>The goal is not a perfectly automated contribution pipeline. It is a process people can understand, correct and sustain. Making the next action explicit is a small improvement with value on both sides of the review.</p>]]></content><author><name>carles</name></author><category term="Open source" /><category term="Code review" /><category term="Developer experience" /><summary type="html"><![CDATA[Distinguish waiting on an author from waiting on a maintainer, using Apicurio's published lifecycle and a real review.]]></summary></entry><entry><title type="html">Rebuilding a Search Index While PostgreSQL Keeps Changing</title><link href="https://carlesarnal.github.io/blog/search-index-recovery/" rel="alternate" type="text/html" title="Rebuilding a Search Index While PostgreSQL Keeps Changing" /><published>2026-10-23T00:00:00+02:00</published><updated>2026-10-23T00:00:00+02:00</updated><id>https://carlesarnal.github.io/blog/search-index-recovery</id><content type="html" xml:base="https://carlesarnal.github.io/blog/search-index-recovery/"><![CDATA[<p>A search index is often described as disposable because the database remains the source of truth. That description is only useful if we know how to rebuild the index while the source continues accepting changes.</p>

<p>Consider a customer record that changes twice during a rebuild, and another that is deleted. A successful bulk load can still leave the replacement index with an old value or a resurrected document. We need a baseline, the subsequent changes, an ordering rule and a controlled cutover.</p>

<p>The example below makes those requirements visible using real PostgreSQL, Debezium, Kafka and Elasticsearch services. It complements schema-governance work: Registry can describe the records, but schema validity cannot prove that a derived view is complete.</p>

<h2 id="keep-the-first-recovery-experiment-small">Keep the first recovery experiment small</h2>

<p>Our lab uses PostgreSQL 16.4, Debezium 3.0.0.Final, Kafka 3.9.1 and Elasticsearch 8.17.0. It captures one table into a <strong>single-partition</strong> topic, retaining its complete history. A Python runner reads actual Kafka events and applies them to Elasticsearch in order.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PostgreSQL --WAL--&gt; Debezium --events--&gt; Kafka partition 0
                                             |
                                      ordered replay
                                             |
                              customers-old / customers-new
                                             |
                                      customers alias
</code></pre></div></div>

<p>This is an explicit replay implementation, not an Elasticsearch sink connector configured behind the scenes. The small scope lets the test assert exact contents at each stage.</p>

<h2 id="run-it">Run it</h2>

<p>Install Python 3 and Docker Compose, then use the maintained branch:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span> talk/evidence-2026-10-23 <span class="se">\</span>
  https://github.com/carlesarnal/debezium-ocp-etc-demo.git
<span class="nb">cd </span>debezium-ocp-etc-demo
python3 recovery.py
</code></pre></div></div>

<p>The runner starts a dedicated Compose project, executes the recovery sequence, writes <code class="language-plaintext highlighter-rouge">results.json</code> and removes its containers and volumes. The services use local, disposable configuration; do not reuse these credentials or exposed development settings for production.</p>

<h2 id="establish-the-baseline">Establish the baseline</h2>

<p>The source table begins with two rows:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">customers</span><span class="p">(</span><span class="n">id</span> <span class="nb">integer</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span> <span class="n">name</span> <span class="nb">text</span><span class="p">);</span>
<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">customers</span> <span class="n">REPLICA</span> <span class="k">IDENTITY</span> <span class="k">FULL</span><span class="p">;</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">customers</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'before'</span><span class="p">),</span> <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'delete-me'</span><span class="p">);</span>
</code></pre></div></div>

<p>Debezium snapshots them as two <code class="language-plaintext highlighter-rouge">r</code> events. The runner verifies that both arrived before it proceeds. That precondition matters: a recovery test that starts from an empty stream can pass while exercising almost none of the intended behavior.</p>

<p>Both rows are applied to <code class="language-plaintext highlighter-rouge">customers-old</code>, and the <code class="language-plaintext highlighter-rouge">customers</code> read alias initially points there. A separate <code class="language-plaintext highlighter-rouge">customers-new</code> index is created for the rebuild.</p>

<h2 id="change-the-source-while-the-replacement-loads">Change the source while the replacement loads</h2>

<p>After loading only the first baseline event into the replacement, the runner performs these writes:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">UPDATE</span> <span class="n">customers</span> <span class="k">SET</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'middle'</span> <span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
<span class="k">UPDATE</span> <span class="n">customers</span> <span class="k">SET</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'after'</span> <span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">customers</span> <span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">customers</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'new'</span><span class="p">);</span>
</code></pre></div></div>

<p>It finishes loading the baseline and waits for all four changes. The captured operation sequence is now:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>r, r, u, u, d, c
</code></pre></div></div>

<p>Record identity is the table’s primary key. Inserts and updates use Elasticsearch document <code class="language-plaintext highlighter-rouge">PUT</code>; deletes use document <code class="language-plaintext highlighter-rouge">DELETE</code>. Replaying the changes in partition order yields:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"1"</span><span class="p">:</span><span class="w"> </span><span class="s2">"after"</span><span class="p">,</span><span class="w"> </span><span class="nl">"3"</span><span class="p">:</span><span class="w"> </span><span class="s2">"new"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The deleted key must be absent. A row count alone would not establish correctness: two wrong documents could still produce the expected count.</p>

<h2 id="replay-again-then-switch-the-alias">Replay again, then switch the alias</h2>

<p>The runner applies the complete retained history again and checks the same final contents. This demonstrates convergence for <strong>complete, ordered replay</strong> in this fixture. It does not show that arbitrary out-of-order updates are safe: an older <code class="language-plaintext highlighter-rouge">PUT</code> after a newer one could overwrite it without an additional version guard.</p>

<p>Once the replacement passes its content checks, the alias changes in one Elasticsearch request:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"actions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"remove"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="s2">"customers-old"</span><span class="p">,</span><span class="w"> </span><span class="nl">"alias"</span><span class="p">:</span><span class="w"> </span><span class="s2">"customers"</span><span class="p">}},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"add"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="s2">"customers-new"</span><span class="p">,</span><span class="w"> </span><span class="nl">"alias"</span><span class="p">:</span><span class="w"> </span><span class="s2">"customers"</span><span class="p">}}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The test queries the alias rather than just the physical index. It then updates customer 3 again in PostgreSQL, captures the seventh event and applies it to the replacement. The alias must now return <code class="language-plaintext highlighter-rouge">after-cutover</code> for that customer. Cutover is not finished if later writes still go only to the old index.</p>

<h2 id="where-a-production-recovery-plan-needs-more-detail">Where a production recovery plan needs more detail</h2>

<p>The complete-history assumption is central. If required events have expired, replay cannot invent the missing baseline. A new snapshot must be coordinated with subsequent changes so neither gaps nor incorrectly ordered updates appear.</p>

<p>For multiple partitions, define progress per partition and the consistency the view requires. Do not compare unrelated offsets as one global clock. Also decide how deletion information survives retention or compaction, how replay resumes after a crash, and whether reads can tolerate remaining lag at cutover.</p>

<p>The fixture does not demonstrate these more complex paths, and it does not make the source and index instantaneously equal while writes continue. It provides a concrete starting point for testing them: known source changes, observed events, exact document assertions and a read-path switch.</p>

<h2 id="make-disposable-an-executable-claim">Make “disposable” an executable claim</h2>

<p>A useful rebuild runbook should answer four questions: where the baseline comes from, how later changes are ordered, what proves the replacement is ready, and where new changes go afterward.</p>

<p>The <a href="https://github.com/carlesarnal/debezium-ocp-etc-demo/tree/talk/evidence-2026-10-23">implementation and recorded result</a> turn those questions into a seven-event integration test. The same reasoning applies to other derived views: recovery is a behavior to exercise, not a property obtained simply by calling a store a cache or an index.</p>]]></content><author><name>carles</name></author><category term="Debezium" /><category term="Elasticsearch" /><category term="Recovery" /><summary type="html"><![CDATA[Seven real CDC events exercise rebuilding, ordered replay, deletion, alias cutover and a write after the switch.]]></summary></entry><entry><title type="html">The Sink Says RUNNING. Where Is My Data? Following CDC into Iceberg</title><link href="https://carlesarnal.github.io/blog/apicurio-registry-iceberg-catalog/" rel="alternate" type="text/html" title="The Sink Says RUNNING. Where Is My Data? Following CDC into Iceberg" /><published>2026-03-25T00:00:00+01:00</published><updated>2026-03-25T00:00:00+01:00</updated><id>https://carlesarnal.github.io/blog/apicurio-registry-iceberg-catalog</id><content type="html" xml:base="https://carlesarnal.github.io/blog/apicurio-registry-iceberg-catalog/"><![CDATA[<p>A Kafka Connect task reports <code class="language-plaintext highlighter-rouge">RUNNING</code>, PostgreSQL keeps accepting writes, and the analytical table does not change. Which component should you investigate first?</p>

<p>I encountered this while assembling a PostgreSQL-to-Iceberg pipeline. The sink’s control consumer could not maintain its group membership, and its coordinator reported commits to zero tables. The task’s lifecycle status was accurate: it was running. It simply was not making data available to the query engine.</p>

<p>This article follows the evidence needed to distinguish those two states. Apicurio Registry is part of the pipeline as the Avro schema service; the Iceberg catalog has a separate responsibility.</p>

<h2 id="map-the-data-path-before-reading-every-log">Map the data path before reading every log</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PostgreSQL -&gt; Debezium -&gt; Kafka -&gt; Iceberg sink -&gt; object storage
                 |                  |                  |
                 +-- Avro schemas --+             data files
                          |                            |
                   Apicurio Registry         Iceberg REST catalog
                                                     |
                                                   Trino
</code></pre></div></div>

<p>Debezium emits changes, Kafka retains them, the sink writes data and commits table metadata, and Trino queries a committed table view. A success at one boundary is not proof that the next boundary succeeded.</p>

<p>The maintained Kubernetes lab uses Registry 3.3.3, the Apache Iceberg Kafka Connect sink 1.11.0 and a separate Iceberg REST catalog fixture. An older version of the repository described Registry as the catalog; that is <strong>not</strong> the architecture reproduced by this lab.</p>

<h2 id="follow-one-key">Follow one key</h2>

<p>Start with a known source change rather than a cluster-wide dashboard. The companion <code class="language-plaintext highlighter-rouge">progress-ledger.txt</code> organizes the investigation:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Evidence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Did the source commit the change?</td>
      <td>Table, primary key, operation and source position</td>
    </tr>
    <tr>
      <td>Did Debezium emit it?</td>
      <td>Matching event and Kafka topic/partition/offset</td>
    </tr>
    <tr>
      <td>Did the sink consume it?</td>
      <td>Progress for the relevant task and partition</td>
    </tr>
    <tr>
      <td>Did a table commit include it?</td>
      <td>Iceberg snapshot and committed data</td>
    </tr>
    <tr>
      <td>Can Trino see it?</td>
      <td>A query result for the expected key</td>
    </tr>
    <tr>
      <td>Does the query represent current state?</td>
      <td>Explicit ordering and delete handling</td>
    </tr>
  </tbody>
</table>

<p>An offset is meaningful in its partition. Comparing the largest offset across unrelated partitions does not establish a global progress boundary.</p>

<p>Registry lookup is another boundary to inspect when deserialization fails. A readable Kafka record does not imply the sink has the schema or references it needs. Conversely, successful schema resolution does not imply that an Iceberg commit completed.</p>

<h2 id="the-control-consumer-was-the-useful-clue">The control consumer was the useful clue</h2>

<p>The failed setup logged <code class="language-plaintext highlighter-rouge">UNKNOWN_MEMBER_ID</code> during the sink control consumer’s classic group-protocol interaction. Commit attempts timed out with <code class="language-plaintext highlighter-rouge">committed to 0 table(s)</code> even while the task reported <code class="language-plaintext highlighter-rouge">RUNNING</code>.</p>

<p>In this setup, changing the sink’s Kafka group protocol restored commits:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">iceberg.kafka.group.protocol</span><span class="pi">:</span> <span class="s">consumer</span>
</code></pre></div></div>

<p>That is an observed recovery, not a universal repair instruction. The underlying interaction was not isolated far enough to claim a root cause for every occurrence of that error. The portable lesson is where to look: the sink has coordination and commit work beyond consuming the data topic.</p>

<h2 id="a-commit-interval-is-not-a-freshness-guarantee">A commit interval is not a freshness guarantee</h2>

<p>Two recorded local runs made an inserted row queryable after <strong>33 seconds</strong> and <strong>42 seconds</strong>, despite this setting:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">iceberg.control.commit.interval-ms</span><span class="pi">:</span> <span class="m">10000</span>
</code></pre></div></div>

<p>The measurements include polling and query overhead. They do not isolate the time spent in capture, consumption, coordination, commit or query visibility. Treating the ten-second setting as an end-to-end guarantee would hide precisely the delays we need to understand.</p>

<p>Likewise, initial snapshot ingestion includes table creation and startup work. Its timing is not directly comparable with a single change flowing through a warmed pipeline. Keep startup and steady-state observations separate before drawing conclusions about freshness.</p>

<h2 id="the-table-is-a-changelog-not-automatically-a-mirror">The table is a changelog, not automatically a mirror</h2>

<p>The example sink appends flattened change events and a <code class="language-plaintext highlighter-rouge">_cdc</code> structure. After an update, the table contains both the original insert and the update event. Seeing the update in a snapshot is different from having a query that returns only the current row.</p>

<p>The example uses a latest-event-per-key query:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">first_name</span><span class="p">,</span> <span class="n">last_name</span><span class="p">,</span> <span class="n">phone</span>
<span class="k">FROM</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="o">*</span><span class="p">,</span>
         <span class="n">row_number</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span>
           <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">id</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">_cdc</span><span class="p">.</span><span class="k">offset</span> <span class="k">DESC</span>
         <span class="p">)</span> <span class="k">AS</span> <span class="n">rn</span>
  <span class="k">FROM</span> <span class="n">iceberg</span><span class="p">.</span><span class="n">inventory</span><span class="p">.</span><span class="n">customers</span>
<span class="p">)</span>
<span class="k">WHERE</span> <span class="n">rn</span> <span class="o">=</span> <span class="mi">1</span> <span class="k">AND</span> <span class="n">_cdc</span><span class="p">.</span><span class="n">op</span> <span class="o">&lt;&gt;</span> <span class="s1">'D'</span><span class="p">;</span>
</code></pre></div></div>

<p>The ordering expression must match the semantics of your connector’s position field and the events being compared. Do not generalize it into a global order across arbitrary partitions or sources. For larger workloads, an append-only changelog also raises questions about compaction, snapshot retention and whether to materialize current state rather than reconstruct it on every query.</p>

<h2 id="try-the-pipeline">Try the pipeline</h2>

<p>The <a href="https://github.com/carlesarnal/database-to-lakehouse-demo/tree/talk/evidence-2026-10-23">maintained repository branch</a> contains the Kubernetes implementation under <code class="language-plaintext highlighter-rouge">talk/lab/</code>. Use a disposable cluster and review its README for the pinned component versions and local image-loading assumptions.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span> talk/evidence-2026-10-23 <span class="se">\</span>
  https://github.com/carlesarnal/database-to-lakehouse-demo.git
<span class="nb">cd </span>database-to-lakehouse-demo/talk/lab
./connect-image/build.sh
./scripts/01-deploy.sh
./scripts/run-demo.sh
./scripts/99-teardown.sh
</code></pre></div></div>

<p>The teardown deletes the lab namespace. The recorded runs used minikube on macOS ARM64; the catalog fixture and single-node services are development infrastructure, not a production availability design.</p>

<h2 id="monitor-the-outcome-users-depend-on">Monitor the outcome users depend on</h2>

<p>Process health, ingestion progress and query freshness answer different questions. Keep all three, but do not let the first stand in for the others.</p>

<p>When the next pipeline stalls, select one key and identify the last boundary with convincing evidence. That gives you a smaller investigation than “Kafka Connect is green but analytics is broken,” and a much better starting point for an alert that reflects what users actually experience.</p>]]></content><author><name>carles</name></author><category term="Iceberg" /><category term="Debezium" /><category term="Kafka" /><summary type="html"><![CDATA[Follow an event through capture, consumption, an Iceberg snapshot and a query instead of equating connector health with progress.]]></summary></entry><entry><title type="html">Why Are Predictions Late? Isolating Backlog in Spark Structured Streaming</title><link href="https://carlesarnal.github.io/blog/realtime-vs-batch-ml-inference/" rel="alternate" type="text/html" title="Why Are Predictions Late? Isolating Backlog in Spark Structured Streaming" /><published>2026-03-12T00:00:00+01:00</published><updated>2026-03-12T00:00:00+01:00</updated><id>https://carlesarnal.github.io/blog/realtime-vs-batch-ml-inference</id><content type="html" xml:base="https://carlesarnal.github.io/blog/realtime-vs-batch-ml-inference/"><![CDATA[<p>An API can return successful responses while its predictions describe events that arrived several seconds or minutes ago. Adding API replicas does not help if the delay sits upstream in a processing queue.</p>

<p>My earlier classification application connected Kafka, Spark and a Quarkus results service. To investigate processing behavior without mixing every stage together, I built a smaller experiment that isolates Spark Structured Streaming. It uses timestamped file input, one small classifier and a deliberately visible admission limit.</p>

<p>The interesting result was not merely that slow processing creates backlog. Removing the injected delay improved prediction age, but did not clear the next bottleneck.</p>

<h2 id="define-the-measurement-boundary">Define the measurement boundary</h2>

<p>The experiment runs Spark 3.5.3 in <code class="language-plaintext highlighter-rouge">local[2]</code> inside a container limited to two CPUs and 3 GB of memory. Its model is a <code class="language-plaintext highlighter-rouge">CountVectorizer</code> and <code class="language-plaintext highlighter-rouge">MultinomialNB</code> pipeline trained on four explicit fixture texts:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">TEXTS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="sh">"</span><span class="s">database query schema</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">football match goal</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">database table index</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">football team stadium</span><span class="sh">"</span>
<span class="p">]</span>
<span class="n">LABELS</span> <span class="o">=</span> <span class="p">[</span><span class="sh">"</span><span class="s">technology</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">sport</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">technology</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">sport</span><span class="sh">"</span><span class="p">]</span>
</code></pre></div></div>

<p>This classifier supplies actual prediction work, but its training set is intentionally tiny. Correct labels for these fixtures demonstrate pipeline execution, not useful generalization or production model quality.</p>

<p>Each input carries an event ID and a new ingress timestamp. The measured age ends when the <code class="language-plaintext highlighter-rouge">foreachBatch</code> callback records the prediction. It includes Spark scheduling and collection plus the classifier and any injected delay; it does <strong>not</strong> include Kafka ingestion, Registry lookups or HTTP query visibility.</p>

<h2 id="run-the-comparison">Run the comparison</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--branch</span> talk/evidence-2026-10-23 <span class="se">\</span>
  https://github.com/carlesarnal/reddit-realtime-classification.git
<span class="nb">cd </span>reddit-realtime-classification
docker build <span class="nt">-t</span> talk-stream-experiment:local experiment
docker run <span class="nt">--rm</span> <span class="nt">--cpus</span> 2 <span class="nt">--memory</span> 3g <span class="se">\</span>
  <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/experiment:/lab"</span> talk-stream-experiment:local
</code></pre></div></div>

<p>The image contains Spark and scikit-learn 1.3.2. The runner writes per-event and per-batch observations to <code class="language-plaintext highlighter-rouge">experiment/results.json</code>. The current branch is the focused experiment; the earlier live-feed application remains in repository history.</p>

<h2 id="make-admission-visible">Make admission visible</h2>

<p>The source has a deliberately restrictive configuration:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">stream</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">spark</span><span class="p">.</span><span class="n">readStream</span><span class="p">.</span><span class="nf">schema</span><span class="p">(</span><span class="n">schema</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">option</span><span class="p">(</span><span class="sh">"</span><span class="s">maxFilesPerTrigger</span><span class="sh">"</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="nf">str</span><span class="p">(</span><span class="n">source</span><span class="p">))</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The query uses a one-second processing-time trigger. Each file contains one event. A warm-up event runs before measurement, then twelve events are offered in each case:</p>

<ol>
  <li><strong>Baseline:</strong> a nominal 1.2 seconds between events, no injected delay.</li>
  <li><strong>Overload:</strong> a nominal 0.1 seconds between events, with 1.2 seconds of injected work per record.</li>
  <li><strong>Delay removed:</strong> the same nominal 0.1-second input interval, with the injected work removed.</li>
</ol>

<p>The actual offered rate is measured, not assumed from <code class="language-plaintext highlighter-rouge">sleep()</code>. File creation, scheduling and system load make the real rate differ from the nominal interval.</p>

<h2 id="read-the-observations-together">Read the observations together</h2>

<p>One local run produced:</p>

<table>
  <thead>
    <tr>
      <th>Case</th>
      <th style="text-align: right">Actual offered events/s</th>
      <th style="text-align: right">Pending when input stopped</th>
      <th style="text-align: right">Median age</th>
      <th style="text-align: right">Maximum age</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Baseline</td>
      <td style="text-align: right">0.80</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0.65 s</td>
      <td style="text-align: right">0.99 s</td>
    </tr>
    <tr>
      <td>Injected overload</td>
      <td style="text-align: right">6.45</td>
      <td style="text-align: right">11</td>
      <td style="text-align: right">8.40 s</td>
      <td style="text-align: right">14.97 s</td>
    </tr>
    <tr>
      <td>Delay removed</td>
      <td style="text-align: right">7.25</td>
      <td style="text-align: right">11</td>
      <td style="text-align: right">5.84 s</td>
      <td style="text-align: right">10.54 s</td>
    </tr>
  </tbody>
</table>

<p>Removing the delay improved age, but it did not make the workload sustainable. The query still admitted only one file per trigger, while files arrived much faster. The pending count at the end of input is a snapshot, not the final loss count: the runner subsequently drains all available input and asserts that all twelve IDs were processed.</p>

<p>These are single-run observations, not a performance benchmark with confidence intervals. The input rates differ somewhat, and the slow case uses an artificial delay rather than claiming that the real classifier takes 1.2 seconds. Those qualifications are essential to interpreting the comparison correctly.</p>

<h2 id="avoid-measuring-the-wrong-age">Avoid measuring the wrong age</h2>

<p>A replay dataset may carry old source timestamps. Reusing them as “ingress time” would make every prediction look old even if processing were immediate. Keep source-event age and time spent in the current pipeline as separate metrics.</p>

<p>The runner records:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>event ID -&gt; ingress -&gt; batch processing start -&gt; result recorded
</code></pre></div></div>

<p>It also records batch duration and event count. These fields let us inspect whether delay accumulated before processing or during the batch. Because <code class="language-plaintext highlighter-rouge">frame.collect()</code> brings records to the driver and the classifier runs there, this particular experiment does not measure distributed executor-side inference throughput.</p>

<h2 id="extend-the-experiment-one-boundary-at-a-time">Extend the experiment one boundary at a time</h2>

<p>For a production diagnosis, repeat runs with representative input and resource limits. Then include the other stages: Kafka arrival, schema resolution if applicable, model completion, output publication and the application’s queryable result.</p>

<p>Changing a micro-batch interval, raising a source-admission cap and adding inference capacity solve different problems. Raising the cap can improve admission but create larger batches or exceed downstream capacity. More API pods may have no effect on any of those limits.</p>

<p>Schema governance and observability complement each other here. A Registry can help preserve the shape and meaning of exchanged data; progress measurements tell us whether that data arrives in time to be useful. Neither a valid schema nor a healthy endpoint establishes freshness.</p>

<p>The <a href="https://github.com/carlesarnal/reddit-realtime-classification/tree/talk/evidence-2026-10-23/experiment">source and raw results</a> make the example reproducible. Use the same discipline on your own pipeline: state the measurement boundary, change one controllable cause, and look for the next limiting stage before adding capacity.</p>]]></content><author><name>carles</name></author><category term="Spark" /><category term="Streaming" /><category term="Performance" /><summary type="html"><![CDATA[A measured Spark experiment shows why removing processing delay can leave a source-admission bottleneck.]]></summary></entry><entry><title type="html">Model Agreement Is a Diagnostic Signal, Not Ground Truth</title><link href="https://carlesarnal.github.io/blog/model-agreement-ground-truth/" rel="alternate" type="text/html" title="Model Agreement Is a Diagnostic Signal, Not Ground Truth" /><published>2026-03-05T00:00:00+01:00</published><updated>2026-03-05T00:00:00+01:00</updated><id>https://carlesarnal.github.io/blog/model-agreement-ground-truth</id><content type="html" xml:base="https://carlesarnal.github.io/blog/model-agreement-ground-truth/"><![CDATA[<p>Running two classifiers on the same input produces a tempting monitoring signal: how often do they agree? It is easy to calculate, available without immediate human labels and useful for finding changes in behavior.</p>

<p>It is not accuracy. Two models can confidently make the same mistake. This article explains how to use agreement without turning it into a guarantee, and how to keep model-quality monitoring separate from the processing measurements in the current streaming experiment.</p>

<h2 id="the-missing-variable-is-the-answer">The missing variable is the answer</h2>

<p>Let <code class="language-plaintext highlighter-rouge">A(x)</code> and <code class="language-plaintext highlighter-rouge">B(x)</code> be two model predictions. Agreement asks whether:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A(x) == B(x)
</code></pre></div></div>

<p>Accuracy requires a label <code class="language-plaintext highlighter-rouge">y(x)</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A(x) == y(x)
</code></pre></div></div>

<p>The first expression contains no evidence about the second. Different architectures can reduce some shared failure patterns, but they can still share training data, label definitions, preprocessing errors or a systematic blind spot.</p>

<p>Here is a deliberately small example:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Model A</th>
      <th>Model B</th>
      <th>Reviewed label</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>sport</td>
      <td>sport</td>
      <td>technology</td>
    </tr>
    <tr>
      <td>2</td>
      <td>sport</td>
      <td>sport</td>
      <td>technology</td>
    </tr>
    <tr>
      <td>3</td>
      <td>technology</td>
      <td>sport</td>
      <td>technology</td>
    </tr>
  </tbody>
</table>

<p>The models agree on two of three inputs, and both agreed answers are wrong. Treating agreement as a confidence score would prioritize the wrong cases for automatic acceptance.</p>

<h2 id="disagreement-is-still-useful">Disagreement is still useful</h2>

<p>Disagreement creates a queue for investigation. It can reveal ambiguous examples, a changed category distribution, a preprocessing bug or one model behaving differently after an update. The signal is a reason to inspect, not a diagnosis by itself.</p>

<p>Keep the model versions, preprocessing version and input identifier with the comparison. Otherwise a jump in disagreement may be difficult to distinguish from a deployment that mixed old and new predictions.</p>

<p>A cross-tabulation of two models’ labels is also not a ground-truth confusion matrix. It shows where the models disagree with <strong>each other</strong>. Use names that make that distinction visible on dashboards and in alerts.</p>

<h2 id="confidence-scores-need-their-own-evaluation">Confidence scores need their own evaluation</h2>

<p>One model reporting 0.95 while another reports 0.70 does not prove the first is more likely to be correct. Confidence scales can differ across architectures and training procedures. Calibration asks whether scores correspond to observed correctness on representative labeled examples.</p>

<p>Without that evaluation, rules such as “prefer the more confident model” simply replace one unverified assumption with another. Even calibrated scores can become unreliable when the input population changes.</p>

<p>An operational policy might instead send selected disagreements for review, use a conservative fallback for high-impact cases, or delay action until a label arrives. The appropriate policy depends on the cost of the error, not only the model scores.</p>

<h2 id="sample-agreements-as-well-as-disagreements">Sample agreements as well as disagreements</h2>

<p>If every reviewed example comes from the disagreement queue, shared mistakes remain invisible. Include a representative sample of agreements, and avoid reporting review-set accuracy as population accuracy when the sampling strategy is biased.</p>

<p>Useful dimensions include category, language, input length and source. A stable global agreement rate can conceal degradation in a small but important segment. Track coverage as well: missing predictions, rejected inputs and timeouts should not silently disappear from the denominator.</p>

<p>When delayed labels become available, connect them back to the predictions made at the time, including their versions. Re-running a newer model on the input answers a different question from evaluating the original decision.</p>

<h2 id="quality-and-freshness-are-separate-axes">Quality and freshness are separate axes</h2>

<p>A correct prediction can arrive too late to be useful. A fast prediction can be wrong. Measuring one does not establish the other.</p>

<p>The maintained <a href="https://github.com/carlesarnal/reddit-realtime-classification/tree/talk/evidence-2026-10-23/experiment">Spark experiment</a> deliberately uses one tiny, fixed classifier. It records ingress and processing timestamps while varying input pacing and an injected processing delay. The fixture labels verify that the expected path executed; four training texts do not establish useful generalization.</p>

<p>That experiment avoids mixing model disagreement with queueing behavior. Its <a href="https://github.com/carlesarnal/distributed-deep-dives/tree/main/realtime-vs-batch-inference">companion article</a> explains the measured backlog and source-admission limit. The older dual-model application remains in repository history, but the current branch should not be described as running a production disagreement-monitoring platform.</p>

<h2 id="a-practical-monitoring-split">A practical monitoring split</h2>

<table>
  <thead>
    <tr>
      <th>Signal</th>
      <th>Useful question</th>
      <th>Additional evidence needed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Agreement rate</td>
      <td>Did the models’ relationship change?</td>
      <td>Representative reviewed labels</td>
    </tr>
    <tr>
      <td>Confidence distribution</td>
      <td>Did score behavior change?</td>
      <td>Calibration and population context</td>
    </tr>
    <tr>
      <td>Label distribution</td>
      <td>Did predicted categories shift?</td>
      <td>Source population and actual labels</td>
    </tr>
    <tr>
      <td>Prediction age</td>
      <td>Are results arriving late?</td>
      <td>Stage timing and offered/processed rates</td>
    </tr>
    <tr>
      <td>Missing-result rate</td>
      <td>Are some inputs excluded?</td>
      <td>Errors, timeouts and filtering policy</td>
    </tr>
  </tbody>
</table>

<p>Schema checks can protect the structure of these records, and feature-input invariants can catch semantic changes before inference. Neither establishes that a classification is right. Use the <a href="https://github.com/carlesarnal/data-contracts-ai-demo">data-contract example</a> for that separate input boundary.</p>

<p>The strongest use of agreement is modest and practical: it helps decide where to look. Keep it beside labeled evaluation, coverage and freshness, and it becomes a useful operational signal without pretending to supply the ground truth you do not yet have.</p>]]></content><author><name>carles</name></author><category term="ML" /><category term="Evaluation" /><category term="Observability" /><summary type="html"><![CDATA[Use model disagreement to guide investigation without confusing agreement, confidence or throughput with accuracy.]]></summary></entry><entry><title type="html">A2A or MCP? Compare the Caller Contract, Not the Label</title><link href="https://carlesarnal.github.io/blog/mcp-servers-domain-ai/" rel="alternate" type="text/html" title="A2A or MCP? Compare the Caller Contract, Not the Label" /><published>2025-12-05T00:00:00+01:00</published><updated>2025-12-05T00:00:00+01:00</updated><id>https://carlesarnal.github.io/blog/mcp-servers-domain-ai</id><content type="html" xml:base="https://carlesarnal.github.io/blog/mcp-servers-domain-ai/"><![CDATA[<p>Suppose you have a service that summarizes text. Should you expose it as an MCP tool or an A2A agent?</p>

<p>The words alone do not decide. An MCP tool can invoke a sophisticated model, and an A2A agent can perform deterministic work. What matters is the contract presented to the caller: discovery, input and output, progress, failure and cancellation.</p>

<p>To compare those contracts, I built a small application that exposes exactly the same function through the official Python SDKs. Removing model variability lets us see the protocol behavior rather than debate two different generated summaries.</p>

<h2 id="hold-the-underlying-work-constant">Hold the underlying work constant</h2>

<p>Our summarizer is intentionally simple:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">summarize</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">text</span><span class="p">.</span><span class="nf">strip</span><span class="p">():</span>
        <span class="k">raise</span> <span class="nc">ValueError</span><span class="p">(</span><span class="sh">"</span><span class="s">text must not be blank</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">return</span> <span class="sh">"</span><span class="s"> </span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">text</span><span class="p">.</span><span class="nf">split</span><span class="p">()[:</span><span class="mi">8</span><span class="p">])</span>
</code></pre></div></div>

<p>Both interfaces receive:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>One implementation can expose multiple interfaces without changing the underlying work.
</code></pre></div></div>

<p>Both must return:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>One implementation can expose multiple interfaces without changing
</code></pre></div></div>

<p>This is not a language-quality test. It gives us a stable oracle for the transport and lifecycle comparison. Once those behaviors are understood, a real model can replace the fixture without changing the questions we ask of its interface.</p>

<h2 id="start-both-servers-and-run-the-comparison">Start both servers and run the comparison</h2>

<p>The example pins A2A SDK <code class="language-plaintext highlighter-rouge">0.3.22</code>, MCP SDK <code class="language-plaintext highlighter-rouge">1.26.0</code>, Uvicorn <code class="language-plaintext highlighter-rouge">0.40.0</code> and HTTPX <code class="language-plaintext highlighter-rouge">0.28.1</code>. Use Python 3.13 and <code class="language-plaintext highlighter-rouge">uv</code> to match the tested client environment:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/carlesarnal/a2a-mcp-comparison.git
<span class="nb">cd </span>a2a-mcp-comparison
uv venv .venv
uv pip <span class="nb">install</span> <span class="nt">--python</span> .venv/bin/python <span class="nt">-r</span> requirements.txt
.venv/bin/python integration.py
.venv/bin/python compare.py <span class="nt">--require-observed</span>
</code></pre></div></div>

<p>The integration runner starts loopback servers on ports <code class="language-plaintext highlighter-rouge">18201</code> and <code class="language-plaintext highlighter-rouge">18202</code>, makes real HTTP calls, records observations and stops both processes. A2A advertises protocol <code class="language-plaintext highlighter-rouge">0.3.0</code>; the MCP session negotiates <code class="language-plaintext highlighter-rouge">2025-11-25</code> in the recorded run.</p>

<h2 id="discovery-exposes-different-application-surfaces">Discovery exposes different application surfaces</h2>

<p>The A2A server publishes an Agent Card with a <code class="language-plaintext highlighter-rouge">summary</code> skill. The test fetches <code class="language-plaintext highlighter-rouge">/.well-known/agent-card.json</code> and verifies that declaration before sending a message.</p>

<p>The MCP client initializes a session and calls <code class="language-plaintext highlighter-rouge">list_tools()</code>. Its <code class="language-plaintext highlighter-rouge">summary</code> tool exposes typed arguments, including text and an optional delay used to make progress observable.</p>

<p>Neither discovery response proves the function works. We still invoke the capability and compare its result with the expected eight-word string. This is also where Registry-backed discovery fits: a registry can help locate and govern metadata, but it does not replace the protocol’s invocation contract.</p>

<h2 id="failure-is-part-of-the-interface">Failure is part of the interface</h2>

<p>Send blank text and the shared function raises <code class="language-plaintext highlighter-rouge">ValueError</code>. The two adapters communicate that failure differently:</p>

<table>
  <thead>
    <tr>
      <th>Requirement</th>
      <th>A2A example</th>
      <th>MCP example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Find the capability</td>
      <td>Agent Card skill</td>
      <td>Tool listing and input schema</td>
    </tr>
    <tr>
      <td>Successful result</td>
      <td>Artifact on a completed task</td>
      <td>Successful tool result</td>
    </tr>
    <tr>
      <td>Blank input</td>
      <td>Task reaches <code class="language-plaintext highlighter-rouge">failed</code></td>
      <td>Tool result has <code class="language-plaintext highlighter-rouge">isError</code></td>
    </tr>
    <tr>
      <td>Observe progress</td>
      <td><code class="language-plaintext highlighter-rouge">working</code> state through <code class="language-plaintext highlighter-rouge">tasks/get</code></td>
      <td>Progress notifications</td>
    </tr>
    <tr>
      <td>Cancel</td>
      <td>Canceled task record</td>
      <td>Client request cancellation</td>
    </tr>
  </tbody>
</table>

<p>These are observations of the supplied implementations, not a complete feature comparison between the standards. In particular, the MCP example does not implement a durable task store, and the A2A example uses an in-memory task store.</p>

<h2 id="cancellation-needs-an-observable-boundary">Cancellation needs an observable boundary</h2>

<p>For A2A, the runner starts delayed work in nonblocking mode, polls until the task is working and sends <code class="language-plaintext highlighter-rouge">tasks/cancel</code>. It then checks for a canceled state and no result artifact. The executor cooperatively observes a stop event.</p>

<p>For MCP, the client starts a delayed tool call and waits for progress before cancelling its pending operation. It observes client-side cancellation and proves that another tool call on the session still works.</p>

<p>Those are not equivalent guarantees. A canceled client operation does not, on its own, prove that server-side computation stopped. Neither example proves recovery after the server process dies. Keeping these boundaries explicit is more useful than declaring that both protocols “support cancellation” and leaving callers to discover what that means.</p>

<h2 id="choose-from-caller-requirements">Choose from caller requirements</h2>

<p>Before choosing an interface, write down what the caller needs:</p>

<ul>
  <li>Is a typed request/result tool sufficient, or does the application need a task it can address later?</li>
  <li>How will progress reach the caller?</li>
  <li>What should a timeout or cancellation mean for work already in progress?</li>
  <li>Which implementation features are advertised, and which have actually been exercised?</li>
  <li>What state must survive a process restart?</li>
</ul>

<p>Exposing both interfaces can be reasonable, but it adds two contracts to maintain. Shared business logic does not remove adapter-specific errors, authentication or lifecycle tests.</p>

<p>The <a href="https://github.com/carlesarnal/a2a-mcp-comparison">repository</a> keeps the implementation, <code class="language-plaintext highlighter-rouge">observations.json</code> and comparison table together. The useful output is not a winner. It is a vocabulary for choosing an interface whose observable behavior matches the application you are building.</p>]]></content><author><name>carles</name></author><category term="A2A" /><category term="MCP" /><category term="API design" /><summary type="html"><![CDATA[The same deterministic function through official A2A and MCP SDKs: compare discovery, errors, progress and cancellation.]]></summary></entry></feed>