<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[The Main Thread]]></title><description><![CDATA[Deep dives into Quarkus, AI tooling, and the architecture decisions that actually matter for senior Java engineers.]]></description><link>https://www.the-main-thread.com</link><image><url>https://substackcdn.com/image/fetch/$s_!8sdd!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81643b8a-6240-4cd1-9f3a-8fd19cc3a455_254x254.png</url><title>The Main Thread</title><link>https://www.the-main-thread.com</link></image><generator>Substack</generator><lastBuildDate>Fri, 24 Jul 2026 21:43:26 GMT</lastBuildDate><atom:link href="https://www.the-main-thread.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Markus Eisele]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[myfear@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[myfear@substack.com]]></itunes:email><itunes:name><![CDATA[Markus Eisele]]></itunes:name></itunes:owner><itunes:author><![CDATA[Markus Eisele]]></itunes:author><googleplay:owner><![CDATA[myfear@substack.com]]></googleplay:owner><googleplay:email><![CDATA[myfear@substack.com]]></googleplay:email><googleplay:author><![CDATA[Markus Eisele]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Progress Users Can Trust for Long-Running Enterprise Jobs]]></title><description><![CDATA[Build an honest two-stage progress UI with Quarkus, PostgreSQL, Scheduler, SSE, cancellation, and state that survives a page reload.]]></description><link>https://www.the-main-thread.com/p/quarkus-long-running-job-progress-sse</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-long-running-job-progress-sse</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Fri, 24 Jul 2026 06:08:04 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7f9ce97b-bdb6-4251-b5b9-52b431c81de2_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Last year I built a <a href="https://www.the-main-thread.com/p/real-time-file-upload-progress-quarkus-java">real-time file upload progress tracker with Quarkus and SSE</a>. It split a file into browser-side chunks, counted bytes in an in-memory map, and pushed each update to an open SSE connection.</p><p>That solves one problem: the user can see bytes crossing the network.</p><p>It does not solve the more common enterprise problem. The file reaches the server, the bar says 100%, and then the real work begins. The application validates 200,000 rows, resolves references, writes batches, calls another system, or generates a report. The browser has no idea how long that work will take. A green upload bar quickly becomes a lie.</p><p>I want to fix that in this follow-up.</p><p>We will build an invoice import on Quarkus 3.37.2 and Java 25. The browser shows two separate clocks:</p><ul><li><p>Transfer progress measures bytes from the browser to the HTTP endpoint.</p></li><li><p>Processing progress measures durable business work after Quarkus accepts the file.</p></li></ul><p>The job lives in PostgreSQL. A basic Quarkus Scheduler worker claims it. Server-Sent Events deliver snapshots, but the connection owns no state. Reload the page halfway through and the UI reconstructs itself from the database. Cancel the job and the worker stops between batches. Restart the application with the same staging volume and startup recovery puts interrupted work back into the queue.</p><p>That is a more useful progress bar because it models the work instead of decorating the request.</p><h2><strong>What we are building</strong></h2><p>The complete companion application is in the <code>honest-progress</code> directory. It includes a 60-row sample CSV and a deliberately visible processing delay so you can watch the state changes.</p><p>The flow looks like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!WdZl!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!WdZl!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 424w, https://substackcdn.com/image/fetch/$s_!WdZl!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 848w, https://substackcdn.com/image/fetch/$s_!WdZl!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 1272w, https://substackcdn.com/image/fetch/$s_!WdZl!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!WdZl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png" width="929" height="541" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:541,&quot;width&quot;:929,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:65606,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/206969136?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!WdZl!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 424w, https://substackcdn.com/image/fetch/$s_!WdZl!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 848w, https://substackcdn.com/image/fetch/$s_!WdZl!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 1272w, https://substackcdn.com/image/fetch/$s_!WdZl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F78b53f43-60a3-4d01-a8ca-b9b26885abb6_929x541.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>There are four REST operations:</p><ul><li><p><code>POST /api/imports</code> accepts the multipart file and returns <code>202 Accepted</code>.</p></li><li><p><code>GET /api/imports/{id}</code> returns one authoritative snapshot.</p></li><li><p><code>GET /api/imports/{id}/events</code> streams changed snapshots as SSE.</p></li><li><p><code>DELETE /api/imports/{id}</code> requests cooperative cancellation.</p></li></ul><p>Notice what is absent: there is no active SSE sink registry and no in-memory progress map. An SSE connection may disappear at any time without changing the job.</p><h2><strong>Prerequisites</strong></h2><p>You need:</p><ul><li><p>Java 25</p></li><li><p>Podman with its Docker-compatible socket enabled</p></li><li><p>A current Quarkus CLI, or Maven if you prefer to create the project directly</p></li><li><p>About &#9749;&#65039;&#9749;&#65039;</p></li></ul><p>PostgreSQL comes from Quarkus Dev Services. With Podman running, there is no database setup for dev mode or tests.</p><p>Create the application or start from my Github repository:</p><pre><code><code>quarkus create app com.themainthread.progress:honest-progress \
  --extension=rest-jackson,hibernate-orm-panache,jdbc-postgresql,scheduler,hibernate-validator \
  --java=25 \
  --no-code

cd honest-progress</code></code></pre><p>I am using the basic <code>quarkus-scheduler</code> extension. It is enough for one application instance and keeps the job lifecycle visible. I will cover the clustered challenges later.</p><h2><strong>Model work, not elapsed time</strong></h2><p>A progress model needs more than a percentage. It needs a state, a phase, completed units, total units, a human-readable message, and an optimistic-lock version.</p><p>My sample uses these states:</p><pre><code><code>package com.themainthread.progress.domain;

public enum JobState {
    QUEUED,
    RUNNING,
    SUCCEEDED,
    FAILED,
    CANCELLED;

    public boolean terminal() {
        return this == SUCCEEDED || this == FAILED || this == CANCELLED;
    }
}</code></code></pre><p>And these phases:</p><pre><code><code>package com.themainthread.progress.domain;

public enum JobPhase {
    QUEUED,
    VALIDATING,
    IMPORTING,
    FINALIZING,
    COMPLETE
}</code></code></pre><p><code>RUNNING</code> tells an operator that a worker owns the job. <code>VALIDATING</code> tells the user what that worker is doing.</p><p>Only the import phase has a decent denominator. During validation, the application does not yet know how many valid rows exist. During finalization, a database transaction may take 50 ms or five seconds, but &#8220;73% committed&#8221; is not a real measurement. Those phases use an indeterminate progress bar.</p><p>The API record calculates a percentage only when it can produce a number:</p><pre><code><code>private static Integer percentage(ImportJob job) {
    if (job.jobState == JobState.SUCCEEDED) {
        return 100;
    }
    if (job.totalUnits == 0
            || job.jobPhase == JobPhase.VALIDATING
            || job.jobPhase == JobPhase.FINALIZING) {
        return null;
    }
    return (int) Math.min(100,
            job.completedUnits * 100 / job.totalUnits);
}</code></code></pre><p>Returning <code>null</code> is not a missing feature. It is a statement that we know the phase but cannot calculate meaningful completion inside it.</p><p>The <code>ImportJob</code> entity persists this state with timestamps, a cancellation flag, the staged file path, and <code>@Version</code>:</p><pre><code><code>package com.themainthread.progress.domain;

import java.time.Instant;
import java.util.UUID;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

@Entity
@Table(name = "import_job")
public class ImportJob {

    @Id
    public UUID id;

    @Column(name = "original_file_name", nullable = false, length = 255)
    public String originalFileName;

    @Column(name = "stored_path", nullable = false, length = 1024)
    public String storedPath;

    @Column(name = "file_size", nullable = false)
    public long fileSize;

    @Enumerated(EnumType.STRING)
    @Column(name = "job_state", nullable = false, length = 32)
    public JobState jobState;

    @Enumerated(EnumType.STRING)
    @Column(name = "job_phase", nullable = false, length = 32)
    public JobPhase jobPhase;

    @Column(name = "completed_units", nullable = false)
    public long completedUnits;

    @Column(name = "total_units", nullable = false)
    public long totalUnits;

    @Column(name = "published_count", nullable = false)
    public long publishedCount;

    @Column(name = "cancellation_requested", nullable = false)
    public boolean cancellationRequested;

    @Column(nullable = false, length = 500)
    public String message;

    @Column(name = "error_message", length = 2000)
    public String errorMessage;

    @Column(name = "created_at", nullable = false)
    public Instant createdAt;

    @Column(name = "updated_at", nullable = false)
    public Instant updatedAt;

    @Column(name = "finished_at")
    public Instant finishedAt;

    @Version
    public long version;
}</code></code></pre><p>The important field for streaming is <code>version</code>. Every committed change increments it, so an SSE endpoint can suppress identical snapshots without maintaining its own business state.</p><h2><strong>Accept the upload, then return a job</strong></h2><p><a href="https://quarkus.io/guides/rest#handling-multipart-form-data">Quarkus REST supports multipart form data through </a><code>FileUpload</code>. The upload endpoint moves Quarkus&#8217; temporary file into application-controlled staging, creates the job, and returns immediately.</p><pre><code><code>package com.themainthread.progress.api;

import java.net.URI;
import java.util.UUID;

import com.themainthread.progress.domain.ImportProgress;
import com.themainthread.progress.job.ProgressStreamService;

import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Multi;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.sse.OutboundSseEvent;
import jakarta.ws.rs.sse.Sse;

import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;

@Path("/api/imports")
@Produces(MediaType.APPLICATION_JSON)
public class ImportResource {

    private final ImportJobService jobs;
    private final ProgressStreamService streams;
    private final Sse sse;

    public ImportResource(ImportJobService jobs,
            ProgressStreamService streams, Sse sse) {
        this.jobs = jobs;
        this.streams = streams;
        this.sse = sse;
    }

    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    public Response create(@NotNull @RestForm("file") FileUpload file,
            @Context UriInfo uriInfo) {
        ImportProgress progress = jobs.create(file);
        URI location = uriInfo.getAbsolutePathBuilder()
                .path(progress.id().toString())
                .build();
        return Response.accepted(progress).location(location).build();
    }

    @GET
    @Path("/{id}")
    public ImportProgress get(@PathParam("id") UUID id) {
        return jobs.snapshot(id);
    }

    @DELETE
    @Path("/{id}")
    public Response cancel(@PathParam("id") UUID id) {
        return Response.accepted(jobs.cancel(id)).build();
    }

    @GET
    @Path("/{id}/events")
    @Produces(MediaType.SERVER_SENT_EVENTS)
    @Blocking
    public Multi&lt;OutboundSseEvent&gt; events(@PathParam("id") UUID id) {
        ImportProgress initial = jobs.snapshot(id);
        return streams.stream(id, initial).map(this::event);
    }

    private OutboundSseEvent event(ImportProgress progress) {
        return sse.newEventBuilder()
                .id(Long.toString(progress.version()))
                .name("progress")
                .reconnectDelay(2000)
                .mediaType(MediaType.APPLICATION_JSON_TYPE)
                .data(ImportProgress.class, progress)
                .build();
    }
}</code></code></pre><p><code>202 Accepted</code> means that the transfer completed and the server accepted a different resource, the import job, for asynchronous processing. The <code>Location</code> header tells the client where to find it.</p><p>The limits must match the endpoint. Quarkus applies a small default limit to multipart form attributes, so raising only the overall body limit is not enough for a large file field:</p><pre><code><code>quarkus.http.limits.max-body-size=26M
quarkus.http.limits.max-form-attribute-size=25M

progress.staging-directory=target/staged-uploads
progress.stream-interval=500ms
progress.processing-delay=150ms
progress.batch-size=5
progress.scheduler-interval=1s

%dev.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%prod.quarkus.hibernate-orm.schema-management.strategy=validate

%test.quarkus.scheduler.enabled=false
%prod.progress.staging-directory=/var/lib/honest-progress/staged-uploads
%prod.progress.processing-delay=0s</code></code></pre><p>The delay exists only to make a 60-row demo observable. The production profile disables it.</p><p>Do not trust an uploaded file name as a path. The sample keeps only <code>Path.of(name).getFileName()</code>, generates the stored name from the job UUID, accepts <code>.csv</code>, and moves the file before the request ends. Quarkus owns the multipart temporary file only for the request lifecycle.</p><h2><strong>Let the scheduler claim durable work</strong></h2><p>The worker is small and boring as usually:</p><pre><code><code>package com.themainthread.progress.job;

import java.util.List;

import com.themainthread.progress.persistence.JobStore;
import com.themainthread.progress.persistence.JobStore.RecoveryAction;
import com.themainthread.progress.storage.FileStorage;

import io.quarkus.runtime.StartupEvent;
import io.quarkus.scheduler.Scheduled;
import io.quarkus.scheduler.Scheduled.ConcurrentExecution;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;

@ApplicationScoped
public class ImportJobScheduler {

    private final JobStore store;
    private final ImportJobProcessor processor;
    private final FileStorage fileStorage;

    public ImportJobScheduler(JobStore store,
            ImportJobProcessor processor, FileStorage fileStorage) {
        this.store = store;
        this.processor = processor;
        this.fileStorage = fileStorage;
    }

    void recover(@Observes StartupEvent event) {
        List&lt;RecoveryAction&gt; actions = store.recoverInterrupted();
        for (RecoveryAction action : actions) {
            if (action.deleteFile()) {
                fileStorage.deleteQuietly(action.storedPath());
            }
        }
    }

    @Scheduled(identity = "invoice-import-runner",
            every = "${progress.scheduler-interval}",
            concurrentExecution = ConcurrentExecution.SKIP)
    public void runNext() {
        store.claimNext().ifPresent(processor::process);
    }
}</code></code></pre><p>The <a href="https://quarkus.io/guides/scheduler">Quarkus Scheduler supports property expressions and skipped concurrent execution</a>. <code>SKIP</code> prevents another trigger from entering this method while the current import is still running in this application instance.</p><p><code>claimNext()</code> locks the oldest queued row and changes it to <code>RUNNING</code> in one transaction. The processor then follows this order:</p><ol><li><p>Validate the complete CSV and count its rows.</p></li><li><p>Change the phase to <code>IMPORTING</code> and set <code>totalUnits</code>.</p></li><li><p>Persist small batches with <code>published=false</code>.</p></li><li><p>Check the cancellation flag between batches.</p></li><li><p>Change the phase to <code>FINALIZING</code>.</p></li><li><p>Publish every staged invoice and mark the job <code>SUCCEEDED</code> in one transaction.</p></li></ol><p>Validation before import avoids predictable partial failures. The unpublished flag handles failures that happen later. If processing fails or the user cancels, the application deletes the staged invoice rows. If the application stops after committing a batch, startup recovery deletes unpublished rows and requeues the job before the scheduler continues.</p><p>This is not one giant transaction. A 200,000-row import should not hold locks and a persistence context for the entire job. Small transactions make progress durable and cancellation responsive. The publication flag keeps those intermediate commits invisible to downstream business queries.</p><p>Cooperative cancellation is equally deliberate. <code>DELETE</code> does not interrupt a Java thread in the middle of a database operation. It sets <code>cancellationRequested=true</code>. The worker finishes its current batch, observes the flag, cleans up, and ends in <code>CANCELLED</code>. That gives the code a safe boundary instead of pretending arbitrary interruption is harmless.</p><h2><strong>Stream database snapshots, not process memory</strong></h2><p>SSE is still the right fit for the second clock. Progress moves from server to browser, the browser API reconnects automatically, and ordinary HTTP remains available for create, read, and cancel commands.</p><p>The stream emits the current snapshot immediately, then checks for version changes on a worker thread:</p><pre><code><code>package com.themainthread.progress.job;

import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

import com.themainthread.progress.config.ProgressConfig;
import com.themainthread.progress.domain.ImportProgress;
import com.themainthread.progress.persistence.JobStore;

import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ProgressStreamService {

    private final JobStore store;
    private final ProgressConfig config;

    public ProgressStreamService(JobStore store, ProgressConfig config) {
        this.store = store;
        this.config = config;
    }

    public Multi&lt;ImportProgress&gt; stream(UUID id, ImportProgress initial) {
        AtomicBoolean terminalSeen = new AtomicBoolean();
        AtomicLong lastVersion = new AtomicLong(Long.MIN_VALUE);
        Multi&lt;ImportProgress&gt; changes = Multi.createFrom().ticks()
                .every(config.streamInterval())
                .onItem().transformToUniAndConcatenate(
                        ignored -&gt; snapshotAsync(id));

        return Multi.createBy().concatenating()
                .streams(Multi.createFrom().item(initial), changes)
                .select().first(progress -&gt;
                        !terminalSeen.getAndSet(progress.terminal()))
                .select().where(progress -&gt;
                        progress.version()
                                != lastVersion.getAndSet(progress.version()));
    }

    private Uni&lt;ImportProgress&gt; snapshotAsync(UUID id) {
        return Uni.createFrom().item(() -&gt; store.snapshot(id))
                .runSubscriptionOn(Infrastructure.getDefaultWorkerPool());
    }
}</code></code></pre><p>The JDBC read is blocking, so <code>runSubscriptionOn</code> moves it away from the event-loop thread. The first terminal snapshot is emitted, one following tick closes the stream, and duplicate entity versions never reach the client.</p><p>Polling PostgreSQL twice per second is a conscious baseline, not a universal prescription. It is easy to reason about, survives reconnects, and is adequate for modest enterprise job volume. At larger scale, publish job IDs through PostgreSQL notifications, Kafka, Redis, or another broker and let the SSE layer re-read the authoritative row before emitting. The database remains the source of truth; the broker becomes a wake-up signal.</p><h2><strong>Use the browser&#8217;s upload progress</strong></h2><p>The previous article chunked the file in JavaScript and counted each accepted chunk on the server. Chunking is justified when you need resumable transfer, retry of individual parts, or object-storage multipart uploads. It is extra protocol surface when you only need to show one normal upload.</p><p>The Fetch API still does not provide the same broadly available upload progress events. <code>XMLHttpRequest.upload</code> does. The <a href="https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/upload">browser fires progress events with </a><code>loaded</code><a href="https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/upload">, </a><code>total</code><a href="https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/upload">, and </a><code>lengthComputable</code>, which is exactly what the transfer bar needs.</p><p>The relevant browser code is small:</p><pre><code><code>function upload(file) {
    const body = new FormData();
    body.append('file', file);

    const request = new XMLHttpRequest();
    request.open('POST', '/api/imports');

    request.upload.addEventListener('progress', event =&gt; {
        if (!event.lengthComputable) {
            transferProgress.removeAttribute('value');
            transferPercent.textContent = 'sending';
            return;
        }
        const percent = Math.round(event.loaded * 100 / event.total);
        transferProgress.value = percent;
        transferPercent.textContent = `${percent}%`;
    });

    request.addEventListener('load', () =&gt; {
        if (request.status !== 202) {
            showUploadError(request.responseText);
            return;
        }

        const job = JSON.parse(request.responseText);
        localStorage.setItem('honest-progress-job', job.id);
        renderJob(job);
        follow(job.id);
    });

    request.send(body);
}</code></code></pre><p>After the <code>202</code>, <code>EventSource</code> owns the second display:</p><pre><code><code>function follow(id) {
    const source = new EventSource(`/api/imports/${id}/events`);

    source.addEventListener('progress', event =&gt; {
        const job = JSON.parse(event.data);
        renderJob(job);

        if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(job.state)) {
            source.close();
            localStorage.removeItem('honest-progress-job');
        }
    });
}</code></code></pre><p>On page load, the full application reads the stored job ID, calls <code>GET /api/imports/{id}</code>, renders that snapshot, and reconnects only if the job is non-terminal. <code>localStorage</code> is a convenience pointer, not trusted job state.</p><p>The UI uses native <code>&lt;progress&gt;</code> elements and an <code>aria-live</code> status message. When <code>percent</code> is <code>null</code>, JavaScript removes the element&#8217;s <code>value</code> attribute and the browser exposes an indeterminate state. A screen-reader user gets the same phase and message as a sighted user rather than a silent animated stripe.</p><h2><strong>Run it and break it</strong></h2><p>Start dev mode:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>Open http://localhost:8080 and click <strong>Run demo file</strong>. You should see the transfer reach 100% first. The job then moves through <code>QUEUED</code>, <code>VALIDATING</code>, <code>IMPORTING</code>, <code>FINALIZING</code>, and <code>SUCCEEDED</code>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!uD6c!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!uD6c!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 424w, https://substackcdn.com/image/fetch/$s_!uD6c!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 848w, https://substackcdn.com/image/fetch/$s_!uD6c!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 1272w, https://substackcdn.com/image/fetch/$s_!uD6c!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!uD6c!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png" width="1456" height="830" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:830,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1651199,&quot;alt&quot;:&quot;Screenshot from the application&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/206969136?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Screenshot from the application" title="Screenshot from the application" srcset="https://substackcdn.com/image/fetch/$s_!uD6c!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 424w, https://substackcdn.com/image/fetch/$s_!uD6c!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 848w, https://substackcdn.com/image/fetch/$s_!uD6c!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 1272w, https://substackcdn.com/image/fetch/$s_!uD6c!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa88dce19-d48a-43e3-abe4-177ddc13144c_3024x1724.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Now run three more checks:</p><ol><li><p>Start another import and reload the page while rows are being imported. The job ID survives locally, the snapshot comes back from PostgreSQL, and SSE resumes.</p></li><li><p>Start an import and press <strong>Cancel job</strong>. A queued job cancels immediately; a running job stops after its current batch.</p></li><li><p>Upload a CSV containing the same invoice number twice. Validation fails before any published row exists, and the job preserves the error for a later <code>GET</code>.</p></li></ol><p>You can create a job without the UI:</p><pre><code><code>curl -i \
  -F file=@src/main/resources/META-INF/resources/sample-invoices.csv \
  http://localhost:8080/api/imports</code></code></pre><p>The response starts like this:</p><pre><code><code>HTTP/1.1 202 Accepted
Location: http://localhost:8080/api/imports/4bd55d0a-...
Content-Type: application/json</code></code></pre><p>Run the tests:</p><pre><code><code>./mvnw test</code></code></pre><p>The seven tests cover successful publication, the terminal SSE event, queued cancellation, duplicate detection with no published partial rows, rejected file types, and focused CSV rules. The scheduler is disabled in the test profile so each integration test calls the worker deterministically.</p><h2><strong>Where the basic scheduler stops scaling</strong></h2><p>This sample has one more boundary: it is a single-instance design.</p><p><code>ConcurrentExecution.SKIP</code> prevents overlap only inside one application instance. If you deploy three replicas, each replica has its own trigger. PostgreSQL locking can protect a job claim, but it does not make local staged files visible to another pod. Startup recovery on one replica must not reset work that another healthy replica owns.</p><p>For multiple replicas, change three elements:</p><ul><li><p>Put uploads in shared object storage instead of a local staging directory.</p></li><li><p>Claim work with an atomic database operation such as <code>FOR UPDATE SKIP LOCKED</code>, plus a lease and heartbeat so abandoned work can be recovered safely.</p></li><li><p>Either let every replica compete for jobs safely or use the <a href="https://quarkus.io/guides/quartz">Quarkus Quartz extension</a> with a clustered store when you need coordinated triggers, calendars, and persistent scheduling.</p></li></ul><p>The browser and API contract do not need to change. <code>POST</code>, <code>GET</code>, <code>DELETE</code>, and SSE still address the same durable job resource.</p><h2><strong>What WebSockets Next would change</strong></h2><p>I did not add <code>quarkus-websockets-next</code> to this application. Progress is server-to-client, while create and cancel are ordinary HTTP commands. SSE matches that asymmetry and gives us a smaller protocol.</p><p><a href="https://quarkus.io/guides/websockets-next-reference">WebSockets Next</a> becomes interesting when the control plane is genuinely bidirectional. Imagine that the user can pause, resume, change priority, answer a validation question, or subscribe to several jobs over one connection. A WebSocket endpoint could receive those commands and push state changes on the same channel.</p><p>That changes the edge architecture, not the job architecture:</p><ul><li><p>The browser opens a WebSocket and sends an explicit <code>subscribe</code> message for a job ID.</p></li><li><p>The server authenticates that subscription and maps connections to authorized jobs.</p></li><li><p>Cancel, pause, and resume become socket messages instead of HTTP methods.</p></li><li><p>Reconnect needs an application protocol: the client must resubscribe and send the last version it saw.</p></li><li><p>Multiple application replicas need shared fan-out through a broker or a routed connection strategy.</p></li></ul><p>PostgreSQL still leads the job execution. The scheduler still processes it. A socket must not become the only place that knows progress or cancellation state, because sockets disconnect and application instances restart.</p><p>Use WebSockets Next when bidirectional interaction is part of managing  lifecycle complexity. Do not add it just to animate a percentage bar.</p><h2><strong>The progress bar is part of the domain</strong></h2><p>The old version asked, &#8220;How many bytes reached Quarkus?&#8221; This version asks a more valuable question: &#8220;What can the application truthfully say about the work right now?&#8221;</p><p>Sometimes the answer is 42 of 200,000 invoices. Sometimes it is &#8220;validating,&#8221; with no percentage. Sometimes it is &#8220;cancellation requested; finishing the current batch.&#8221; Those are not UI details. They are business state, and they deserve the same durability and failure semantics as the import itself.</p><p>Keep the two clocks separate. Return a job resource when the upload ends. Persist every meaningful transition. Treat SSE or WebSockets as delivery mechanisms, not memory. Then 100% can mean what users think it means: the work is actually done.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Quarkus TOTP with Vault: What Production-Ready Step-Up Requires]]></title><description><![CDATA[Turn one valid TOTP code into a two-minute step-up JWT, then let Quarkus RBAC protect the sensitive operation and test replay against Vault.]]></description><link>https://www.the-main-thread.com/p/quarkus-vault-totp-step-up-auth</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-vault-totp-step-up-auth</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Wed, 22 Jul 2026 06:09:13 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/5f73c2f6-1a96-4d22-9faf-8f86923f45fc_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://quarkus.io/blog/quarkus-insights-253-build-analytics/">Quarkus Insights #253</a> lists HashiCorp Vault among the 20 most-used Quarkiverse extensions in the opt-in build analytics. Its usage also increased notably from May 2025 to May 2026. More Quarkus applications are using Vault, so this is a good time to revisit the integration.</p><p>I first covered it in the short May 2025 article <a href="https://www.the-main-thread.com/p/secure-java-api-totp-quarkus-vault">Add TOTP Authentication to Your Java API with Quarkus and Vault Dev Services</a>. That example started Vault with Dev Services, created a TOTP key, displayed a QR code, and validated a six-digit code.</p><p>That was enough to show the Vault API, but not a complete authentication flow. The example trusted an <code>X-User</code> header and sent the TOTP code directly to the protected endpoint. It had no first-factor token, enrollment state, or replay test. It also required a manual step in the Vault UI to enable the TOTP engine.</p><p>This article builds the missing parts. A signed access token represents the first login. The user exchanges one valid TOTP code for a step-up JWT that expires after two minutes. Only that token carries the <code>payout:approve</code> role. The payout endpoint then uses normal Quarkus RBAC. The TOTP code raises assurance once; it does not become another credential on every business request.</p><p>We also store the enrollment mapping, mark the QR response as <code>no-store</code>, validate the code format, and test that Vault rejects a used code.</p><p>One version detail matters for this setup. Quarkus Vault 4.8.0 is current as I write this, but its <a href="https://github.com/quarkiverse/quarkus-vault/blob/4.8.0/runtime/src/main/java/io/quarkus/vault/runtime/VaultVersions.java">Dev Services source still defaults to Vault 1.15.2</a>. Vault fixed a <a href="https://discuss.hashicorp.com/t/hcsec-2025-17-vault-totp-secrets-engine-code-reuse/76036">TOTP code-reuse vulnerability</a> in 1.15.15, 1.16.23, 1.18.12, 1.19.7, and 1.20.1. The Java extension and the Vault server have separate versions. We will set the current Vault 2.0.3 image explicitly.</p><h2><strong>What we are building</strong></h2><p>The request flow has three credentials with different jobs:</p><ol><li><p>A normal bearer token represents the authenticated subject <code>alice</code> and carries the <code>user</code> role.</p></li><li><p>A valid TOTP code raises assurance. LedgerLock consumes it once and returns a step-up JWT that expires after 120 seconds.</p></li><li><p>The step-up JWT carries <code>payout:approve</code>, so Quarkus allows the sensitive operation.</p></li></ol><p>The step-up token also contains <code>"amr": ["pwd", "otp"]</code>. The names come from <a href="https://www.rfc-editor.org/rfc/rfc8176.html">RFC 8176</a>, which defines authentication-method reference values that a relying service can inspect. <code>amr</code> describes how the user authenticated. The role still decides what the token may do.</p><p>LedgerLock issues both tokens so the example stays local. The development token endpoint simulates the result of a first-factor login; it does not verify a password. In a system with Keycloak, Auth0, Entra ID, or another OpenID Connect provider, that provider would normally own the first factor and often the second one too. </p><h2><strong>What you need</strong></h2><p>This article uses Quarkus 3.37.2, Quarkus Vault 4.8.0, Java 21, and Vault 2.0.3.</p><ul><li><p>Java 25 or newer</p></li><li><p>The Quarkus CLI</p></li><li><p>Podman with a running machine</p></li><li><p><code>curl</code> and <code>jq</code></p></li><li><p>An authenticator app that supports TOTP</p></li><li><p>About &#9749;&#65039;&#9749;&#65039;&#9749;&#65039;&#9749;&#65039; (&lt; security topics are hard!)</p></li></ul><h2><strong>Create the application</strong></h2><p>Create the project or <a href="https://github.com/myfear/the-main-thread/tree/main/vault-totp-step-up">start from my Github repository</a>:</p><pre><code><code>quarkus create app com.themainthread:vault-totp-step-up \
  --extension='rest-jackson,smallrye-jwt,smallrye-jwt-build,hibernate-validator,hibernate-orm-panache,jdbc-h2,smallrye-health' \
  --no-code</code></code></pre><p><code>quarkus-rest-jackson</code> gives us the JSON endpoints. SmallRye JWT verifies the bearer tokens and signs the local step-up token. Hibernate Validator rejects malformed TOTP input before it reaches Vault. Panache and H2 keep the subject-to-Vault-key mapping for this runnable example, and SmallRye Health exposes the Vault readiness check.</p><p>Vault is a Quarkiverse extension, so add its current version explicitly:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.quarkiverse.vault&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-vault&lt;/artifactId&gt;
    &lt;version&gt;4.8.0&lt;/version&gt;
&lt;/dependency&gt;</code></code></pre><h2><strong>Configure Vault Dev Services</strong></h2><p>Add the following to <code>src/main/resources/application.properties</code>:</p><pre><code><code># Vault Dev Services starts only in dev and test mode.
quarkus.vault.devservices.image-name=hashicorp/vault:2.0.3
quarkus.vault.devservices.shared=false
quarkus.vault.devservices.init-commands=secrets enable totp
quarkus.vault.health.enabled=true

# The tutorial keeps enrollment state local so it needs no second container.
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:mem:ledgerlock;DB_CLOSE_DELAY=-1
quarkus.hibernate-orm.schema-management.strategy=drop-and-create

# SmallRye JWT generates a signing key pair automatically in dev and test mode.
mp.jwt.verify.issuer=https://ledgerlock.local
mp.jwt.verify.audiences=ledgerlock-api
mp.jwt.verify.clock.skew=5
smallrye.jwt.new-token.issuer=https://ledgerlock.local
smallrye.jwt.new-token.audience=ledgerlock-api
smallrye.jwt.new-token.lifespan=300

# Vault is a request dependency for enrollment and verification.
quarkus.vault.connect-timeout=3S
quarkus.vault.read-timeout=3S</code></code></pre><p>Dev Services already knows how to start Vault and inject the URL and root token in dev and test mode. It does not enable the TOTP secrets engine by default. <code>init-commands</code> passes <code>secrets enable totp</code> to the container during startup, which removes the manual Vault UI step that older examples often needed.</p><p>Set the image explicitly. HashiCorp recommends using the latest fix release within a supported line, and the old 1.15.2 default predates the TOTP replay fix. Vault 2.0.3 is the current release, and the test below runs against it. Vault 2.x contains breaking changes, so review the <a href="https://developer.hashicorp.com/vault/docs/updates/release-notes">2.x release notes</a> and <a href="https://developer.hashicorp.com/vault/docs/upgrade">upgrade guidance</a> before changing an existing production cluster.</p><p>Quarkus also generates an in-memory JWT key pair in dev and test mode. Production needs a real signing key, a JWK set, or an identity provider. The build prints a warning when no production key is configured.</p><h2><strong>Keep enrollment state outside the TOTP seed</strong></h2><p>Vault stores the TOTP seed and performs code validation. LedgerLock still needs to know which opaque Vault key belongs to each authenticated subject. Create <code>TotpEnrollment.java</code>:</p><pre><code><code>package com.themainthread.ledgerlock;

import java.time.Instant;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "totp_enrollment")
public class TotpEnrollment extends PanacheEntityBase {

    @Id
    @Column(nullable = false, updatable = false)
    public String subject;

    @Column(name = "vault_key", nullable = false, unique = true, updatable = false)
    public String vaultKey;

    @Column(name = "created_at", nullable = false, updatable = false)
    public Instant createdAt;
}</code></code></pre><p>The database stores no TOTP seed. <code>vaultKey</code> is a random identifier such as <code>user-86483632-4d7b-498f-ae59-b4998d62b53e</code>. Using an opaque value keeps email addresses and usernames out of Vault paths and audit entries.</p><p>An enrollment already present for the subject returns <code>409 Conflict</code>. Rotation needs its own flow that asks for an existing factor or a recovery credential. Otherwise, a stolen access token would be enough to replace the second factor.</p><h2><strong>Put the Vault interaction in one service</strong></h2><p>Create <code>TotpService.java</code>:</p><pre><code><code>package com.themainthread.ledgerlock;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

import org.jboss.logging.Logger;

import io.quarkus.vault.VaultTOTPSecretEngine;
import io.quarkus.vault.client.VaultClientException;
import io.quarkus.vault.secrets.totp.CreateKeyParameters;
import io.quarkus.vault.secrets.totp.KeyDefinition;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.PersistenceException;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.core.Response;

@ApplicationScoped
public class TotpService {

    private static final Logger LOG = Logger.getLogger(TotpService.class);

    private final VaultTOTPSecretEngine totpEngine;

    public TotpService(VaultTOTPSecretEngine totpEngine) {
        this.totpEngine = totpEngine;
    }

    @Transactional
    public EnrollmentResponse enroll(String subject) {
        if (TotpEnrollment.findById(subject) != null) {
            throw new ClientErrorException("TOTP is already enrolled", Response.Status.CONFLICT);
        }

        String vaultKey = "user-" + UUID.randomUUID();
        CreateKeyParameters parameters = new CreateKeyParameters("LedgerLock", subject);
        parameters.setExported(true);
        parameters.setPeriod("30s");
        parameters.setAlgorithm("SHA1");
        parameters.setDigits(6);
        parameters.setSkew(1);
        parameters.setQrSize(240);

        Optional&lt;KeyDefinition&gt; created = totpEngine.createKey(vaultKey, parameters);
        KeyDefinition definition = created.orElseThrow(
                () -&gt; new IllegalStateException("Vault did not export the TOTP enrollment data"));

        TotpEnrollment enrollment = new TotpEnrollment();
        enrollment.subject = subject;
        enrollment.vaultKey = vaultKey;
        enrollment.createdAt = Instant.now();

        try {
            enrollment.persistAndFlush();
        } catch (PersistenceException failure) {
            deleteCompensatingKey(vaultKey);
            throw new ClientErrorException("TOTP enrollment already exists", Response.Status.CONFLICT, failure);
        }

        return new EnrollmentResponse(
                "data:image/png;base64," + definition.getBarcode(),
                definition.getUrl());
    }

    public boolean validate(String subject, String code) {
        TotpEnrollment enrollment = TotpEnrollment.findById(subject);
        if (enrollment == null) {
            return false;
        }

        try {
            return totpEngine.validateCode(enrollment.vaultKey, code);
        } catch (VaultClientException failure) {
            if (failure.getStatus() == 400 &amp;&amp; failure.hasErrorContaining("code already used")) {
                return false;
            }
            throw failure;
        }
    }

    private void deleteCompensatingKey(String vaultKey) {
        try {
            totpEngine.deleteKey(vaultKey);
        } catch (RuntimeException cleanupFailure) {
            LOG.warnf(cleanupFailure, "Could not remove orphaned Vault TOTP key %s", vaultKey);
        }
    }

    public record EnrollmentResponse(String qrCodeDataUrl, String manualEntryUri) {
    }
}</code></code></pre><p>The constructor <code>new CreateKeyParameters("LedgerLock", subject)</code> asks Vault to generate the seed. <code>exported=true</code> returns the QR code and <code>otpauth://</code> URI once so the user can bind an authenticator app. The <a href="https://developer.hashicorp.com/vault/api-docs/secret/totp">Vault TOTP API</a> also supports imported seeds, different periods, eight-digit codes, SHA-256, and SHA-512. Six digits, a 30-second period, and SHA-1 work with most authenticator apps. SHA-1 is the HMAC algorithm used for TOTP compatibility here. We do not use it to hash a password.</p><p><code>skew=1</code> accepts a neighboring time step to handle normal clock drift. This helps users near a 30-second boundary, but it also increases the number of accepted codes. Keep clocks synchronized and only increase the window when you have a clear reason.</p><p>The database transaction cannot include Vault. If Vault creates a key and the database insert then loses a uniqueness race, the catch block deletes the orphaned key. There are still crash windows between those two systems. A production enrollment service needs reconciliation for orphaned keys and records, usually with an explicit <code>PENDING</code> state.</p><p>One Vault behavior is easy to miss. A wrong code normally returns <code>false</code>. A reused code makes patched Vault return HTTP 400 with <code>code already used; wait until the next time period</code>. The Quarkus wrapper turns that response into <code>VaultClientException</code>. Reuse is an authentication failure, so the service maps this specific response to <code>false</code>. Other Vault failures still escape because they are operational errors.</p><h2><strong>Exchange the code for higher assurance</strong></h2><p>The enrollment response contains the TOTP seed, whether represented as a QR image or a URI. It should be delivered once over TLS and never cached. The resource also requires an already authenticated user.</p><p>Create <code>TotpResource.java</code>:</p><pre><code><code>package com.themainthread.ledgerlock;

import org.eclipse.microprofile.jwt.JsonWebToken;

import jakarta.annotation.security.RolesAllowed;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/api/totp")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("user")
public class TotpResource {

    private final JsonWebToken accessToken;
    private final TotpService totpService;
    private final StepUpTokenService stepUpTokenService;

    public TotpResource(JsonWebToken accessToken, TotpService totpService, StepUpTokenService stepUpTokenService) {
        this.accessToken = accessToken;
        this.totpService = totpService;
        this.stepUpTokenService = stepUpTokenService;
    }

    @POST
    @Path("/enrollment")
    public Response enroll() {
        TotpService.EnrollmentResponse enrollment = totpService.enroll(accessToken.getSubject());
        CacheControl noStore = new CacheControl();
        noStore.setNoStore(true);
        noStore.setNoCache(true);
        return Response.status(Response.Status.CREATED)
                .cacheControl(noStore)
                .header("Pragma", "no-cache")
                .entity(enrollment)
                .build();
    }

    @POST
    @Path("/step-up")
    public StepUpResponse stepUp(@Valid OtpRequest request) {
        String subject = accessToken.getSubject();
        if (!totpService.validate(subject, request.code())) {
            throw new ClientErrorException("The TOTP code is invalid or was already used", Response.Status.UNAUTHORIZED);
        }

        return new StepUpResponse(stepUpTokenService.issueFor(subject), StepUpTokenService.LIFESPAN_SECONDS);
    }

    public record OtpRequest(
            @NotBlank @Pattern(regexp = "[0-9]{6}", message = "must contain exactly six ASCII digits") String code) {
    }

    public record StepUpResponse(String token, long expiresInSeconds) {
    }
}</code></code></pre><p><code>[0-9]{6}</code> makes the ASCII input boundary explicit, and the resource does not trim the value. The 2025 Vault vulnerability made used-code cache entries bypassable by appending whitespace. The patched server fixes that flaw, and the resource rejects the malformed value before it crosses the network. Both controls are cheap.</p><p>Now create the token service:</p><pre><code><code>package com.themainthread.ledgerlock;

import java.time.Instant;
import java.util.List;
import java.util.Set;

import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class StepUpTokenService {

    static final long LIFESPAN_SECONDS = 120;

    public String issueFor(String subject) {
        return Jwt.subject(subject)
                .upn(subject)
                .groups(Set.of("user", "payout:approve"))
                .claim("amr", List.of("pwd", "otp"))
                .claim("acr", "urn:ledgerlock:assurance:mfa")
                .expiresAt(Instant.now().plusSeconds(LIFESPAN_SECONDS))
                .sign();
    }
}</code></code></pre><p>The token keeps the same subject and adds the narrow role needed for the payout. Its <code>amr</code> claim records that password and OTP methods were used. The <code>acr</code> value is private to LedgerLock and means the token met our local MFA policy. Other services must agree on that meaning before they trust it.</p><p>The token expires after two minutes, so the extra role is available only for a short time. This example allows more than one payout during that window. For one high-value transaction, bind the token to a payout ID or operation hash and consume its <code>jti</code> once.</p><h2><strong>Protect the business operation with normal RBAC</strong></h2><p>Create <code>PayoutResource.java</code>:</p><pre><code><code>package com.themainthread.ledgerlock;

import java.math.BigDecimal;
import java.util.UUID;

import org.eclipse.microprofile.jwt.JsonWebToken;

import jakarta.annotation.security.RolesAllowed;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/api/payouts")
@Produces(MediaType.APPLICATION_JSON)
public class PayoutResource {

    private final JsonWebToken accessToken;

    public PayoutResource(JsonWebToken accessToken) {
        this.accessToken = accessToken;
    }

    @POST
    @RolesAllowed("payout:approve")
    public Response approve(@Valid PayoutRequest request) {
        ApprovedPayout payout = new ApprovedPayout(
                UUID.randomUUID(),
                request.recipient(),
                request.amount(),
                accessToken.getSubject(),
                "APPROVED");
        return Response.status(Response.Status.ACCEPTED).entity(payout).build();
    }

    public record PayoutRequest(
            @NotBlank String recipient,
            @NotNull @DecimalMin("0.01") BigDecimal amount) {
    }

    public record ApprovedPayout(
            UUID id,
            String recipient,
            BigDecimal amount,
            String approvedBy,
            String status) {
    }
}</code></code></pre><p>The payout code knows nothing about Vault or six-digit codes. Quarkus verifies the JWT and enforces <code>@RolesAllowed</code>. TOTP raises the authentication level once, and the business endpoint only checks the resulting role.</p><h2><strong>Stand in for the first factor in local development</strong></h2><p>We still need a normal access token to start the flow. This development-only endpoint issues one with the <code>user</code> role:</p><pre><code><code>package com.themainthread.ledgerlock;

import java.util.List;
import java.util.Set;

import io.quarkus.arc.profile.UnlessBuildProfile;
import io.smallrye.jwt.build.Jwt;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/dev/token")
@Produces(MediaType.APPLICATION_JSON)
@UnlessBuildProfile("prod")
public class DevTokenResource {

    @GET
    @Path("/{subject}")
    public TokenResponse token(@PathParam("subject") String subject) {
        String token = Jwt.subject(subject)
                .upn(subject)
                .groups(Set.of("user"))
                .claim("amr", List.of("pwd"))
                .sign();
        return new TokenResponse(token);
    }

    public record TokenResponse(String token) {
    }
}</code></code></pre><p><code>@UnlessBuildProfile("prod")</code> removes the resource from a production build. It is a local replacement for the access token your identity provider would issue after the first-factor login. A production token endpoint with no login would bypass every other control in this example.</p><h2><strong>Run the full flow</strong></h2><p>Start the application with Podman available:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>Quarkus starts Vault 2.0.3, enables the TOTP engine, creates the H2 schema, and generates the dev JWT key pair. Check readiness:</p><pre><code><code>curl -s http://localhost:8080/q/health/ready | jq</code></code></pre><p>The response should include an <code>UP</code> check for Vault.</p><p>Get Alice&#8217;s first-factor token:</p><pre><code><code>TOKEN=$(curl -s http://localhost:8080/dev/token/alice | jq -r .token)</code></code></pre><p>Enroll the authenticator:</p><pre><code><code>curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  http://localhost:8080/api/totp/enrollment \
  | tee enrollment.json</code></code></pre><p>The response contains <code>qrCodeDataUrl</code> and <code>manualEntryUri</code>. Both contain the TOTP seed, so keep <code>enrollment.json</code> only long enough to bind the authenticator. To display the QR code, create a small local HTML file:</p><pre><code><code>printf '&lt;img src="%s"&gt;' "$(jq -r .qrCodeDataUrl enrollment.json)" &gt; enrollment.html</code></code></pre><p>Open <code>enrollment.html</code> in a browser and scan it. Then replace <code>123456</code> below with the current code:</p><pre><code><code>STEP_TOKEN=$(curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"code":"123456"}' \
  http://localhost:8080/api/totp/step-up \
  | jq -r .token)</code></code></pre><p>The first token still cannot approve a payout:</p><pre><code><code>curl -i -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"recipient":"supplier-42","amount":1250.00}' \
  http://localhost:8080/api/payouts</code></code></pre><p>Expected status:</p><pre><code><code>HTTP/1.1 403 Forbidden</code></code></pre><p>Use the step-up token:</p><pre><code><code>curl -s -X POST \
  -H "Authorization: Bearer $STEP_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"recipient":"supplier-42","amount":1250.00}' \
  http://localhost:8080/api/payouts \
  | jq</code></code></pre><p>You should get an accepted payout:</p><pre><code><code>{
  "id": "a1e0d0d9-0f19-4da3-8d11-099aeb7314b1",
  "recipient": "supplier-42",
  "amount": 1250.00,
  "approvedBy": "alice",
  "status": "APPROVED"
}</code></code></pre><p>Send the same TOTP code to <code>/api/totp/step-up</code> again during its 30-second window. The API returns <code>401 Unauthorized</code>. Add a trailing space and it returns <code>400 Bad Request</code> before Vault receives it.</p><h2><strong>Prove replay handling in a test</strong></h2><p>The manual flow shows the user experience. The automated test asks Vault to generate the code for the enrolled key, then proves the security properties without waiting for somebody to type from a phone:</p><pre><code><code>package com.themainthread.ledgerlock;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.startsWith;

import java.math.BigDecimal;
import java.util.Map;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.vault.VaultTOTPSecretEngine;
import jakarta.inject.Inject;

@QuarkusTest
class TotpStepUpTest {

    @Inject
    VaultTOTPSecretEngine totpEngine;

    @Test
    void aTotpCodeCreatesAShortLivedStepUpTokenAndCannotBeReplayed() {
        String firstFactorToken = given()
                .when().get("/dev/token/alice")
                .then()
                .statusCode(200)
                .extract().path("token");

        given()
                .auth().oauth2(firstFactorToken)
                .when().post("/api/totp/enrollment")
                .then()
                .statusCode(201)
                .header("Cache-Control", startsWith("no-cache"))
                .body("qrCodeDataUrl", startsWith("data:image/png;base64,"))
                .body("manualEntryUri", startsWith("otpauth://totp/"));

        String vaultKey = totpEngine.listKeys().getFirst();
        String code = totpEngine.generateCode(vaultKey);

        String stepUpToken = given()
                .auth().oauth2(firstFactorToken)
                .contentType("application/json")
                .body(Map.of("code", code))
                .when().post("/api/totp/step-up")
                .then()
                .statusCode(200)
                .body("expiresInSeconds", equalTo(120))
                .extract().path("token");

        given()
                .auth().oauth2(firstFactorToken)
                .contentType("application/json")
                .body(new PayoutResource.PayoutRequest("supplier-42", new BigDecimal("1250.00")))
                .when().post("/api/payouts")
                .then()
                .statusCode(403);

        given()
                .auth().oauth2(stepUpToken)
                .contentType("application/json")
                .body(new PayoutResource.PayoutRequest("supplier-42", new BigDecimal("1250.00")))
                .when().post("/api/payouts")
                .then()
                .statusCode(202)
                .body("approvedBy", equalTo("alice"))
                .body("status", equalTo("APPROVED"));

        given()
                .auth().oauth2(firstFactorToken)
                .contentType("application/json")
                .body(Map.of("code", code))
                .when().post("/api/totp/step-up")
                .then()
                .statusCode(401);

        given()
                .auth().oauth2(firstFactorToken)
                .contentType("application/json")
                .body(Map.of("code", code + " "))
                .when().post("/api/totp/step-up")
                .then()
                .statusCode(400);
    }
}</code></code></pre><p>Run it:</p><pre><code><code>./mvnw test</code></code></pre><p>The test uses the same pinned Vault Dev Service as dev mode. The Vault version is part of the test fixture, so changing it runs the replay test again.</p><h2><strong>Before you use this in production</strong></h2><p>The example now has a clear two-factor flow. A production TOTP service still needs lifecycle and abuse controls that are outside this small application.</p><p><strong>Let the identity provider own MFA when it can.</strong> If your OpenID Connect provider already enrolls authenticators and issues trustworthy <code>amr</code> and <code>acr</code> claims, consume those claims in Quarkus. An application-owned TOTP verifier makes sense for a dedicated authentication service, a controlled internal platform, or a legacy boundary where the central provider cannot express the required step-up flow.</p><p><strong>Give the application a narrow Vault identity.</strong> Dev Services uses a root token because the container is disposable. Production should use Kubernetes auth, AppRole, or another machine authentication method with TLS and a policy limited to the application&#8217;s <code>totp/keys/...</code> and <code>totp/code/...</code> paths. The application validates codes; it should not have permission to generate user codes from <code>GET /totp/code/:name</code>.</p><p><strong>Rate-limit failures by account and context.</strong> A six-digit code has a small online search space. <a href="https://pages.nist.gov/800-63-4/sp800-63b.html">NIST SP 800-63B-4</a> requires throttling for online guessing and treats 100 consecutive failures as an upper bound, not a target. Use a much lower risk-based threshold where your user population and recovery process allow it. Put the counter in shared storage, an API gateway, or the identity service so adding Quarkus replicas does not multiply the guess budget.</p><p><strong>Build recovery and rotation deliberately.</strong> Users lose phones. They also replace them. Recovery codes need hashed storage, one-time consumption, monitoring, and a notification path. Rotation should require an existing factor or a verified recovery process. Keep enrollment states such as <code>PENDING</code>, <code>ACTIVE</code>, and <code>REVOKED</code>, and delete abandoned Vault keys after a bounded time.</p><p><strong>Keep the QR response out of logs and caches.</strong> The QR image and <code>otpauth://</code> URI both carry the seed. <code>Cache-Control: no-store</code> helps with HTTP caches, but your reverse proxy, tracing, body logging, browser history, and support tooling also need review. Never log <code>KeyDefinition</code>; its <code>toString()</code> includes the barcode and URI.</p><p><strong>Decide what happens when Vault is down.</strong> The health check can mark the service unready, but that may remove every replica at once during a Vault outage. Existing step-up tokens can still pass local JWT verification until they expire. New enrollment and verification cannot. Choose that failure mode consciously, monitor it, and keep the Vault read timeout short.</p><p><strong>TOTP is not phishing-resistant.</strong> Manually entered OTPs are replay-resistant when the verifier accepts each value once, but <a href="https://pages.nist.gov/800-63-4/sp800-63b.html#phish">NIST does not consider them phishing-resistant</a>. A fake login page can relay a current code. Passkeys and WebAuthn bind authentication to the verifier and are the stronger default for new public-facing systems. TOTP remains relevant for compatibility, controlled enterprise environments, and recovery paths. Treat it as one supported authentication level, not the final design for every system.</p><h2><strong>Keep the responsibilities separate</strong></h2><p>Vault owns the TOTP seed and the one-time validation rule. Quarkus owns the authenticated subject, enrollment state, and authorization decision. The six-digit code crosses that boundary once and becomes a short-lived claim that ordinary RBAC can enforce.</p><p>This is a better design than adding <code>X-TOTP-Code</code> to every protected request. The first-factor result is a signed token. The sensitive operation asks for fresh assurance. We test replay behavior against the real Vault runtime, and the business endpoint stays free of authentication code.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Use skillsaw to Lint Bob Skills Before They Drift]]></title><description><![CDATA[Build a small invoice-processing Bob skill, add a reference file, wire CI, and test the behavior locally before the instructions spread across repos.]]></description><link>https://www.the-main-thread.com/p/skillsaw-bob-skills</link><guid isPermaLink="false">https://www.the-main-thread.com/p/skillsaw-bob-skills</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Mon, 20 Jul 2026 06:08:36 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/3b181692-70c5-4489-8f87-6f7c5ebb4533_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A <code>SKILL.md</code> file is easy to write and maintain when there is only one copy.</p><p>Then it gets copied into a second repo. Then a third. One copy still points at an old path. One still has placeholder text. One quietly relaxed the rules because somebody wanted to get through a demo. At one point the file is part of the system. It needs the same kind of care you already give to scripts and config.</p><p>That is why I like <a href="https://github.com/stbenjam/skillsaw">skillsaw</a>. It treats skill files like artifacts you can inspect and lint. The tool focuses on file quality: wrong shape, bad naming, leaked placeholder text, vague instructions. That catches a lot of drift early.</p><p>The example here is artificial on purpose. We use one small Bob skill called <code>invoice-processor</code>. It validates invoice totals, writes results to one output file, logs mismatches, preserves input order, and forbids shell commands. The rules are narrow, which makes them easy to inspect. </p><p>The result is a small skill folder, a clean <code>skillsaw</code> loop, a GitHub Actions check, and a Bob prompt that runs on a local <code>input.json</code> file with no external service, no MCP server, and no API keys.</p><h2><strong>What You Need</strong></h2><p>You should already be comfortable editing Markdown, using a terminal, and running IBM Bob inside an IDE. </p><ul><li><p>Python 3.11 or newer</p></li><li><p>IBM Bob in your IDE (<a href="http://bob.ibm.com/download">download a trial</a> if you like)</p></li><li><p>GitHub Actions if you want the CI part</p></li><li><p>About &#9749;&#65039;</p></li></ul><h2><strong>Create The Skill Repo</strong></h2><p>First decide who owns the skill. That decision is way more important than the directory name.</p><p>Use a repo-local skill when the rules only make sense inside one codebase. In that case, keep the skill with the project, for example under <code>.bob/skills</code>, and review it with the same pull request that changes the workflow it controls.</p><p>Use a separate skill repo when the skill is shared across several repos, several teams, or several projects. That repo becomes the source of truth. It should have its own lint check, release tags, and a small changelog so consumers can tell which skill release they installed.</p><p>Use personal skill directories only for experiments or private habits. They are fine for local work. They are a bad place to hide team behavior because nobody else can review or reproduce them.</p><p>For this tutorial we use a separate repo because we want to show the full <code>skillsaw</code> loop: lint the skill, pin the rule set, run CI, and then install the checked skill into Bob.</p><p>Create the layout:</p><pre><code><code>mkdir -p main-thread-skills/invoice-processor/references
cd main-thread-skills
python3 -m venv .venv
.venv/bin/python -m pip install skillsaw==0.16.0</code></code></pre><p>If you already use <code>uv</code>, the <a href="https://skillsaw.org/">skillsaw quick start</a> also supports <code>uvx skillsaw</code>. </p><p>Start with a bad skill on purpose. The lint output is easier to read when the file is obviously rough.</p><p>Create <code>invoice-processor/SKILL.md</code>:</p><pre><code><code>---
name: Invoice Processor
description: process invoices
---

# Invoice Processor

Process invoice batches.

You can use shell scripts if that is faster.

Write results somewhere under out/.

Maybe stop on errors.

TODO: tighten this.
</code></code></pre><p>This looks small if you skim it. It also gives Bob far too much room to improvise.</p><h2><strong>Read The Tree First</strong></h2><p>Before linting anything, ask <code>skillsaw</code> to show what it found in the repo.</p><p>Run:</p><pre><code><code>.venv/bin/skillsaw tree .</code></code></pre><p>For this example, the output is:</p><pre><code><code>main-thread-skills/ (51 tokens)
    &#9492;&#9472;&#9472; invoice-processor/ [skill] (51 tokens)
        &#9492;&#9472;&#9472; SKILL.md (skill) (51 tokens)
            &#9500;&#9472;&#9472; frontmatter:name (4 tokens)
            &#9500;&#9472;&#9472; frontmatter:description (4 tokens)
            &#9492;&#9472;&#9472; body (43 tokens)</code></code></pre><p>If <code>skillsaw</code> does not even see a skill, stop there and fix the layout first.</p><h2><strong>Lint The Rough Draft</strong></h2><p><span>Stay in `main-thread-skills`. That is the repo root for this example.</span></p><p><span>Now run a different subcommand: `lint`, not `tree`:</span></p><pre><code><code>.venv/bin/skillsaw lint .</code></code></pre><p>On the rough draft above, you should see findings in this shape:</p><pre><code><code>skillsaw 0.16.0
Linting: /path/to/main-thread-skills

Errors:
  &#10007; ERROR (agentskill-name) [invoice-processor/SKILL.md:2]: Name 'Invoice Processor' must contain only lowercase letters, numbers, and hyphens

Warnings:
  &#9888; WARNING (content-placeholder-text) [invoice-processor/SKILL.md:16]: Placeholder text (TODO marker): 'TODO'</code></code></pre><p>The output points at the real problem quickly: the instructions are sloppy.</p><p>If you want to find out more about rule warnings and what they mean, ask the tool:</p><pre><code><code>.venv/bin/skillsaw explain agentskill-name
.venv/bin/skillsaw explain content-placeholder-text</code></code></pre><p>Make sure to agree, as a team, on those definitions. We do not want them to feel arbitrary to anyone.</p><h2><strong>Use Auto-Fix For Mechanical Problems</strong></h2><p><code>skillsaw fix</code> handles the mechanical cleanup.</p><p>Preview the changes first:</p><pre><code><code>.venv/bin/skillsaw fix --dry-run .</code></code></pre><p>For the bad front matter above, the preview shows a diff like this:</p><pre><code><code>--- a/invoice-processor/SKILL.md
+++ b/invoice-processor/SKILL.md
@@ -1,5 +1,5 @@
 ---
-name: Invoice Processor
+name: invoice-processor
  description: process invoices
 ---</code></code></pre><p>Then apply the safe fixes:</p><pre><code><code>.venv/bin/skillsaw fix -y .</code></code></pre><p>Renaming the front matter takes seconds. Writing rules that leave Bob less room to guess takes longer. </p><h2><strong>Write The Real Skill</strong></h2><p>Now write the version Bob can use in real work.</p><p>This skill is narrow on purpose. It covers one batch job, one input file, one output file, one error log, and one response shape. That makes it a good target for <code>skillsaw</code> because vague wording shows up fast.</p><p>Create <code>invoice-processor/SKILL.md</code>:</p><pre><code><code>---
name: invoice-processor
description: &gt;
  Process invoice batches from input.json, validate totals against line items,
  write validated output to out/invoices.json, log mismatches to errors.log,
  and return a JSON summary.
---

# Invoice Processor

Read [validation examples](references/validation-examples.md) when you need an example of valid and invalid totals. The numbered rules below are the source of truth.

Rules for processing invoice batches. Follow every rule exactly.

1. MUST validate every invoice total against the sum of its line-item amounts (`quantity * unitPrice`) before writing output.
2. MUST NOT execute shell commands. Process invoices using only file read/write tools. Do not run scripts, terminals, or any command execution.
3. MUST write validated results to `out/invoices.json` and nowhere else. Do not write invoice data to any other path.
4. MUST process invoices in the exact order they appear in `input.json`. Do not sort or reorder.
5. On validation failure (`total` does not match line-item sum), MUST log the invoice ID and discrepancy to `errors.log` and continue processing the remaining invoices. Do not abort the batch.
6. Final response to the user MUST be valid JSON matching this shape exactly: `{"processed": N, "failed": N}` where `N` is the count of successfully validated and failed invoices respectively.</code></code></pre><p>This version closes the gaps from the first draft. It makes the shell ban, output path, failure handling, and response format explicit. The reference link gives Bob examples without moving the rules out of the main file.</p><h2><strong>Add A Reference File</strong></h2><p>Reference files help when the main skill should stay short but Bob still needs examples, glossary entries, workspace notes, or longer decision rules.</p><p>Create <code>invoice-processor/references/validation-examples.md</code>:</p><pre><code><code># Validation Examples

Use these examples to check invoice total behavior.

## Valid Total

Calculate each line item as `quantity * unitPrice`.
Add the line-item amounts.
Compare the sum with the invoice `total`.

```json
{
  "id": "INV-001",
  "total": 25,
  "lineItems": [
    { "quantity": 1, "unitPrice": 10 },
    { "quantity": 3, "unitPrice": 5 }
  ]
}
```

This invoice passes because `10 + 15 = 25`.

## Invalid Total

Calculate the same way for failures.
Record the invoice ID and discrepancy.
Continue with the next invoice.

```json
{
  "id": "INV-002",
  "total": 18,
  "lineItems": [
    { "quantity": 2, "unitPrice": 5 },
    { "quantity": 1, "unitPrice": 7 }
  ]
}
```

The second invoice has a discrepancy of `1`.</code></code></pre><p>Run <code>tree</code> again:</p><pre><code><code>.venv/bin/skillsaw tree .</code></code></pre><p>Now <code>skillsaw</code> shows the reference as <code>skill-ref</code>:</p><pre><code><code>main-thread-skills/ (442 tokens)
    &#9492;&#9472;&#9472; invoice-processor/ [skill] (442 tokens)
        &#9500;&#9472;&#9472; SKILL.md (skill) (329 tokens)
        &#9474;   &#9500;&#9472;&#9472; frontmatter:name (4 tokens)
        &#9474;   &#9500;&#9472;&#9472; frontmatter:description (44 tokens)
        &#9474;   &#9492;&#9472;&#9472; body (281 tokens)
        &#9492;&#9472;&#9472; validation-examples.md (skill-ref) (113 tokens)</code></code></pre><p>I also tried a Python helper under <code>scripts/validate_invoice.py</code>. <code>scripts/</code> is an allowed skill subdirectory, along with <code>assets</code>, <code>evals</code>, and <code>references</code>, but <code>skillsaw 0.16.0</code> did not content-lint that Python file in this setup. A <code>TODO</code> comment inside the script did not trigger <code>content-placeholder-text</code>.</p><p>That does not make scripts bad. It means <code>skillsaw</code> is checking skill structure and instruction content. It does not replace Ruff, Pyright, or your normal code checks. </p><h2><strong>Lint The Finished Skill</strong></h2><p>Run the linter again:</p><pre><code><code>.venv/bin/skillsaw lint -v .</code></code></pre><p>On this version, the run passes with no errors and no warnings. <code>skillsaw</code> still prints two info-level suggestions:</p><pre><code><code>Info:
  &#8505; INFO (content-actionability-score) [invoice-processor/references/validation-examples.md]: Low actionability score: 18/100 (verbs: 17%, commands: 33%, paths: 0%)
  &#8505; INFO (content-unlinked-internal-reference) [invoice-processor/SKILL.md:17]: Unlinked path reference: 'out/invoices.json' &#8212; consider wrapping in link syntax [out/invoices.json](out/invoices.json)

&#10003; All checks passed!</code></code></pre><p>I would leave both alone. The reference file is example-heavy by design, so a low actionability score is not surprising. The bare <code>out/invoices.json</code> path is also part of a hard constraint, and I do not want to turn that sentence into link noise only to satisfy an info rule. <code>skillsaw</code> makes that distinction visible: the file passes, and the tool still points out things you may want to clean up later.</p><h2><strong>Pin The Rule Set</strong></h2><p>Once the skill is in decent shape, generate a config file:</p><pre><code><code>.venv/bin/skillsaw init .</code></code></pre><p>This writes <code>.skillsaw.yaml</code> with the current <code>skillsaw</code> rule set pinned in it:</p><pre><code><code>version: "0.16.0"</code></code></pre><p>That <code>version</code> is not the version of the <code>invoice-processor</code> skill. It is the <code>skillsaw</code> config and rule-set version. We do still want to pin the specific skillsaw version because new rules added after <code>0.16.0</code> do not start changing CI behavior until you choose to bump the config and potentially add new linters.</p><p>If you want to release your skill as <code>1.0.0</code>, do that separately with normal repo versioning: a Git tag, release notes, or a small changelog. If you want to package reusable skills instead of copying folders around, I covered one Java-friendly way in <a href="https://www.the-main-thread.com/p/skillsjars-for-java-package-reusable">SkillsJars for Java: Package Reusable Agent Skills</a>. Do not use <code>.skillsaw.yaml</code> for skill releases.</p><p>If you are adding <code>skillsaw</code> to an older internal repo with a pile of existing issues, this is where the baseline feature helps:</p><pre><code><code>.venv/bin/skillsaw baseline</code></code></pre><p>For a new repo like this one, I would rather fix the warnings than baseline them. Baseline helps when history is already messy. It should not be the main workflow.</p><h2><strong>Add CI</strong></h2><p>Now make CI fail when somebody reintroduces placeholders, weak naming, or other soft edits.</p><p>Create <code>.github/workflows/skillsaw.yml</code>:</p><pre><code><code>name: skillsaw

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  lint-skills:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: stbenjam/skillsaw@v0
        with:
          path: .
          strict: true</code></code></pre><p><code>strict: true</code> matters here because warnings are often the first sign that the wording is getting soft before the file is clearly broken.</p><h2><strong>Other Features Worth Knowing</strong></h2><p>This tutorial uses the core loop: <code>tree</code>, <code>lint</code>, <code>explain</code>, <code>fix</code>, <code>init</code>, <code>baseline</code>, and CI. <code>skillsaw</code> has more surface area once you move past the first repo.</p><ul><li><p><code>list-rules</code> shows every built-in rule and its default severity.</p></li><li><p><code>--rule</code> and <code>--skip-rule</code> let you run a focused check while debugging one rule.</p></li><li><p><code>--format</code> and <code>--output</code> can write <code>json</code>, <code>sarif</code>, <code>html</code>, <code>code-climate</code>, or <code>gitlab</code> reports for other tools.</p></li><li><p><code>badge</code> writes a shields.io-compatible <code>.skillsaw-badge.json</code> with the current grade.</p></li><li><p><code>add</code> scaffolds marketplaces, plugins, skills, commands, agents, and hooks.</p></li><li><p><code>docs</code> generates documentation for plugins, marketplaces, and <code>.claude</code> repositories. It is less relevant for this tiny agentskills repo, but it matters once skills live inside a plugin.</p></li><li><p><code>evals/evals.json</code> is validated when present. Requiring evals for every skill is opt-in with <code>agentskill-evals-required</code>.</p></li><li><p><code>.skillsaw.yaml</code> supports global excludes, per-rule excludes, inline suppressions, extra <code>content-paths</code>, and local custom rule files.</p></li></ul><p>For untrusted pull requests, run CI with <code>--no-custom-rules</code>. Built-in rules are the safer default. Custom Python rules are code execution, so treat them with the same caution you would give any other contributor-controlled script.</p><h2><strong>Install The Skill Into Bob</strong></h2><p>For a local install, Bob needs the files where it can read them. The tutorial uses a copy command because it is easy to inspect.</p><p>In the repo where you want Bob to use the skill, copy it into <code>.bob/skills</code>.</p><p>For a team, avoid hand-copying forever. Pick one repeatable install method and document it: a small sync script, a Git submodule, a Git subtree, or a release archive that gets unpacked into <code>.bob/skills</code>. The boring best practice is that every consuming repo can answer three questions: where the skill came from, which skill release it uses, and how CI proves the installed copy still passes <code>skillsaw</code>.</p><h2><strong>Create A Tiny Test Workspace</strong></h2><p>Create a small test repo or scratch directory with one <code>input.json</code> file:</p><pre><code><code>[
  {
    "id": "INV-001",
    "total": 25,
    "lineItems": [
      { "quantity": 1, "unitPrice": 10 },
      { "quantity": 3, "unitPrice": 5 }
    ]
  },
  {
    "id": "INV-002",
    "total": 18,
    "lineItems": [
      { "quantity": 2, "unitPrice": 5 },
      { "quantity": 1, "unitPrice": 7 }
    ]
  }
]</code></code></pre><p><code>INV-001</code> is valid. <code>INV-002</code> fails validation. The line items sum to <code>17</code>, while the invoice total says <code>18</code>. That gives us one clean pass and one clean failure in the same run.</p><p>Create the output directory before you hand this to Bob:</p><pre><code><code>mkdir -p out</code></code></pre><h2><strong>Use It In Bob</strong></h2><p>Now give Bob a task that looks like normal local work.</p><p>Use a prompt like this:</p><pre><code><code>Use invoice-processor to process input.json. </code></code></pre><p>Watch the behavior.</p><p>Check that Bob does all of the following:</p><ul><li><p>Reads <code>input.json</code></p></li><li><p>Keeps invoices in the original order</p></li><li><p>Writes validated output only to <code>out/invoices.json</code></p></li><li><p>Logs the failed invoice ID and discrepancy to <code>errors.log</code></p></li><li><p>Continues processing after the bad invoice instead of aborting</p></li><li><p>Returns only JSON in the exact shape <code>{"processed": N, "failed": N}</code></p></li><li><p>Avoids shell commands entirely</p></li></ul><p>For the sample data above, the expected final response shape is:</p><pre><code><code>{"processed": 1, "failed": 1}</code></code></pre><p>If Bob opens a terminal, writes invoice data to another path, reorders the records, or wraps the JSON in prose, the skill is still too vague or the tool constraints are not being followed.</p><h2><strong>Why This Version Works Better</strong></h2><p>The rough version was shorter. It was also loose enough that Bob had to guess.</p><p>The finished skill is still small, but the boundaries are clear: one input file, one output file, one error log, one processing order, one response shape, and one hard ban on shell commands. That is what I want in a real repo. The file stays short, but it is specific enough that a reviewer can tell what should happen.</p><p><code>skillsaw</code> works well for this because it catches the plain failures early: wrong front matter, placeholder text, weak naming, and general sloppiness before those files spread into more repos.</p><p>We built one Bob skill, linted it, wired CI, installed it into <code>.bob/skills</code>, and tested it on a local batch with no external setup. Once a <code>SKILL.md</code> changes agent behavior, treat it like code.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Put OPA in Front of Your Quarkus MCP Tools]]></title><description><![CDATA[Use structured policy input and caller context to decide which MCP tools a client can see and call.]]></description><link>https://www.the-main-thread.com/p/opa-quarkus-mcp-tools</link><guid isPermaLink="false">https://www.the-main-thread.com/p/opa-quarkus-mcp-tools</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Fri, 17 Jul 2026 06:08:27 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/eceed8af-7cb2-46c0-97cb-a627fe355329_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<pre><code><code>opa_decision={"allow":false,"skillId":"pptx_export","reasonCodes":["SCOPE_NOT_ALLOWED"]}
</code></code></pre><p>That log line records an enforced decision. The filesystem restriction lives in policy. It does not depends on a model hopefully following an instruction.</p><p>Prompt guardrails inspect language before or after a model call. The recent <a href="https://quarkus.io/blog/quarkus-langchain4j-opa-guardrails/">Quarkus OPA guardrail article</a> shows how to compile a Rego policy to WebAssembly and evaluate it inside a Quarkus LangChain4j application. Its policy reads prompt text, including regular expressions. To be completely honest, I am not a big fan of prompt inspection with RegEx. It just does not scale. And is just a weak and potentially harmful approach which will never solve the challenges you will experience in guardrails. <a href="https://www.the-main-thread.com/p/multilingual-prompt-injection-guardrails-quarkus-langchain4j">I&#8217;ve written about this before.</a></p><p>But there are great use-cases for this. In this example, OPA reads facts that the application already knows: the user, agent, session, skill publisher, signature status, and requested scopes.</p><p>I use Fernbank as a fictional internal agent platform in this example. Teams publish document generators, database helpers, and deployment skills into a shared catalog. A scanner can flag a suspicious manifest in CI, but the report does not block runtime access. Fernbank needs a gate at the point where it exposes the tool.</p><p>We build the gate as a Quarkus MCP server. OPA evaluates every tool against its manifest and the caller context. Allowed tools appear in <code>tools/list</code> and can be called. Denied tools stay out of the list, and a direct <code>tools/call</code> also fails. Each evaluation writes the policy version and stable reason codes to a fixed-size audit buffer.</p><h2><strong>The Quarkus Enforcement Boundary</strong></h2><p>We need two related controls. They run at different times and solve different problems.</p><p><strong>Catalog admission</strong> decides whether a skill may enter the shared catalog. It runs once per skill version and uses publisher data, signature status, source information, and declared capabilities.</p><p><strong>Per-client exposure</strong> decides whether a connected user, agent, or session may see and invoke a tool. It runs for each caller context.</p><p>Quarkus MCP Server discovers annotation-based tools at build time. The OPA filter does not prevent the JVM from loading the class. The extension&#8217;s <code>ToolFilter</code><a href="https://docs.quarkiverse.io/quarkus-mcp-server/1.13.x/guides-using-filters-and-checks.html"> API</a> controls whether a registered tool is visible and accessible for the current MCP connection. We use that hook for per-client exposure.</p><p>If Fernbank loaded third-party plugin code dynamically, I would add a catalog-admission job before global registration through <code>ToolManager</code>. A tool registered with <code>ToolManager</code> is available across the server. <code>ToolFilter</code> still decides which subset each client sees.</p><p>The request path is:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!oM8B!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!oM8B!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 424w, https://substackcdn.com/image/fetch/$s_!oM8B!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 848w, https://substackcdn.com/image/fetch/$s_!oM8B!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 1272w, https://substackcdn.com/image/fetch/$s_!oM8B!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!oM8B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png" width="784" height="136" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:136,&quot;width&quot;:784,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:12560,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/206657428?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!oM8B!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 424w, https://substackcdn.com/image/fetch/$s_!oM8B!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 848w, https://substackcdn.com/image/fetch/$s_!oM8B!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 1272w, https://substackcdn.com/image/fetch/$s_!oM8B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57d03fef-dd62-4221-ae2e-8cae03b9b465_784x136.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>The <a href="https://docs.quarkiverse.io/quarkus-mcp-server/1.13.x/guides-using-filters-and-checks.html">Quarkus MCP filter documentation</a> says that filters execute on the Vert.x event loop. They must finish quickly. A synchronous HTTP call to a separate OPA sidecar process would block that thread, so we compile Rego to Wasm and evaluate it in the Quarkus process.</p><p>The extension also ignores a filter when that filter throws an exception. Our filter catches policy failures and returns <code>false</code>. A failed policy evaluation therefore denies access.</p><h2><strong>What You Need</strong></h2><p>This example uses Quarkus 3.37.2, Quarkus MCP Server 1.13.1, Java 25, OPA 1.17.0, and <code>opa-java-wasm</code> 1.1.0. The verification covers JVM mode. I have not tested this Wasm runtime in a Quarkus native executable, so native mode is outside the scope of this example.</p><ul><li><p>Java 25</p></li><li><p>Podman with its machine running on macOS or Windows</p></li><li><p>The Quarkus CLI</p></li><li><p>Basic MCP and Rego knowledge</p></li><li><p>About &#9749;&#65039;&#9749;&#65039;</p></li></ul><h2><strong>Create the Quarkus MCP Server</strong></h2><p>Create the application or <a href="https://github.com/myfear/the-main-thread/tree/main/fernbank-skill-admission">start from my Github repository</a>:</p><pre><code><code>quarkus create app com.themainthread:fernbank-skill-admission \
  --platform-bom=io.quarkus.platform:quarkus-bom:3.37.2 \
  --java=25 \
  --extension=rest-jackson,io.quarkiverse.mcp:quarkus-mcp-server-http \
  --no-code
cd fernbank-skill-admission</code></code></pre><p><code>quarkus-mcp-server-http</code> provides the Streamable HTTP MCP endpoint and the filter API. <code>quarkus-rest-jackson</code> maps manifests, policy input, policy output, and the decision-log endpoint. In this version, the Quarkus platform BOM manages the MCP extension.</p><p>Add the Wasm evaluator and the MCP test client to <code>pom.xml</code>:</p><pre><code><code>&lt;properties&gt;
    &lt;opa-java-wasm.version&gt;1.1.0&lt;/opa-java-wasm.version&gt;
&lt;/properties&gt;

&lt;dependencies&gt;
    &lt;dependency&gt;
        &lt;groupId&gt;com.styra.opa&lt;/groupId&gt;
        &lt;artifactId&gt;opa-java-wasm&lt;/artifactId&gt;
        &lt;version&gt;${opa-java-wasm.version}&lt;/version&gt;
    &lt;/dependency&gt;
    &lt;dependency&gt;
        &lt;groupId&gt;io.quarkiverse.mcp&lt;/groupId&gt;
        &lt;artifactId&gt;quarkus-mcp-server-test&lt;/artifactId&gt;
        &lt;scope&gt;test&lt;/scope&gt;
    &lt;/dependency&gt;
&lt;/dependencies&gt;</code></code></pre><p>The <a href="https://quarkus.io/blog/quarkus-langchain4j-opa-guardrails/">Quarkus guardrail example</a> from the earlier mentioned blog post uses the Styra library too. Our code changes the policy input and evaluates it from an MCP filter.</p><h2><strong>Use the Manifest as Policy Input</strong></h2><p>The first tool belongs to Fernbank&#8217;s internal documentation team. We put its manifest in <code>src/main/resources/skills/docs_generate.json</code>:</p><pre><code><code>{
  "skill_id": "docs_generate",
  "publisher": "internal:docs-platform",
  "publisher_trust_tier": "internal-verified",
  "signature_verified": true,
  "requested_scopes": ["context:read", "filesystem:write"],
  "declared_capabilities": ["document-generation"],
  "allowed_teams": ["content", "platform"]
}</code></code></pre><p>The third-party exporter requests filesystem and network access:</p><pre><code><code>{
  "skill_id": "pptx_export",
  "publisher": "third-party:acme-skills",
  "publisher_trust_tier": "third-party-unverified",
  "signature_verified": true,
  "requested_scopes": ["filesystem:write", "network:egress", "context:read"],
  "declared_capabilities": ["document-generation"],
  "allowed_teams": ["content"]
}</code></code></pre><p>The unsigned internal skill only requests <code>context:read</code>. This lets us test signature enforcement without also triggering a scope rule:</p><pre><code><code>{
  "skill_id": "unsigned_status",
  "publisher": "internal:ops-lab",
  "publisher_trust_tier": "internal-unverified",
  "signature_verified": false,
  "requested_scopes": ["context:read"],
  "declared_capabilities": ["status-reporting"],
  "allowed_teams": ["platform"]
}</code></code></pre><p>A catalog pipeline can potentially provide these claims. Fernbank verifies the signature before it sets <code>signature_verified</code>. OPA uses the result of that check. It does not verify the signature itself.</p><p>Map the JSON with records. <code>SkillManifest.java</code> lists every catalog field used by the policy:</p><pre><code><code>package com.themainthread.fernbank;

import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;

public record SkillManifest(
        @JsonProperty("skill_id") String skillId,
        String publisher,
        @JsonProperty("publisher_trust_tier") String publisherTrustTier,
        @JsonProperty("signature_verified") boolean signatureVerified,
        @JsonProperty("requested_scopes") List&lt;String&gt; requestedScopes,
        @JsonProperty("declared_capabilities") List&lt;String&gt; declaredCapabilities,
        @JsonProperty("allowed_teams") List&lt;String&gt; allowedTeams) {
}</code></code></pre><p>Keep caller context separate from the manifest:</p><pre><code><code>package com.themainthread.fernbank;

import com.fasterxml.jackson.annotation.JsonProperty;

public record SubjectContext(
        @JsonProperty("user_id") String userId,
        @JsonProperty("agent_id") String agentId,
        @JsonProperty("session_id") String sessionId,
        String team) {
}</code></code></pre><pre><code><code>package com.themainthread.fernbank;

import com.fasterxml.jackson.annotation.JsonProperty;

public record AdmissionInput(
        SubjectContext subject,
        SkillManifest skill,
        @JsonProperty("runtime_environment") String runtimeEnvironment,
        String action) {
}</code></code></pre><p>In this simple example the <code>SkillCatalog</code> loads the three known manifests once at startup. In a real system, this data would come from a database or a signed artifact store. The filter still needs the same lookup method:</p><pre><code><code>package com.themainthread.fernbank;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class SkillCatalog {

    private static final List&lt;String&gt; MANIFEST_PATHS = List.of(
            "/skills/docs_generate.json",
            "/skills/pptx_export.json",
            "/skills/unsigned_status.json");

    private final ObjectMapper objectMapper;
    private Map&lt;String, SkillManifest&gt; manifests;

    SkillCatalog(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @PostConstruct
    void load() {
        manifests = MANIFEST_PATHS.stream()
                .map(this::readManifest)
                .collect(Collectors.toUnmodifiableMap(SkillManifest::skillId, Function.identity()));
    }

    public Optional&lt;SkillManifest&gt; find(String skillId) {
        return Optional.ofNullable(manifests.get(skillId));
    }

    private SkillManifest readManifest(String path) {
        try (InputStream stream = SkillCatalog.class.getResourceAsStream(path)) {
            if (stream == null) {
                throw new IllegalStateException("Skill manifest not found: " + path);
            }
            return objectMapper.readValue(stream, SkillManifest.class);
        } catch (IOException e) {
            throw new IllegalStateException("Cannot read skill manifest: " + path, e);
        }
    }
}</code></code></pre><p>A skill can pass catalog admission and still be unavailable to a specific team. Caller privileges also cannot make an invalid signature valid.</p><h2><strong>Return Reasons From Rego</strong></h2><p>Create <code>src/main/resources/policies/skill-admission.rego</code>:</p><pre><code><code>package fernbank.admission

import rego.v1

policy_version := "2026-07-12"

default allow := false

allowed_scopes := {
    "internal-verified": {
        "context:read",
        "database:read",
        "filesystem:read",
        "filesystem:write",
        "network:egress",
    },
    "internal-unverified": {
        "context:read",
        "filesystem:read",
    },
    "third-party-verified": {
        "context:read",
        "filesystem:read",
    },
    "third-party-unverified": {
        "context:read",
    },
}

known_trust_tier if {
    allowed_scopes[input.skill.publisher_trust_tier]
}

scope_allowed(scope) if {
    scope in allowed_scopes[input.skill.publisher_trust_tier]
}

team_allowed if {
    input.subject.team in input.skill.allowed_teams
}

deny contains {
    "code": "TEAM_NOT_AUTHORIZED",
    "message": sprintf("team %q is not allowed to use this skill", [input.subject.team]),
} if {
    not team_allowed
}

deny contains {
    "code": "UNKNOWN_TRUST_TIER",
    "message": sprintf("publisher trust tier %q is not configured", [input.skill.publisher_trust_tier]),
} if {
    not known_trust_tier
}

deny contains {
    "code": "PROD_SIGNATURE_REQUIRED",
    "message": "production requires a verified skill signature",
} if {
    input.runtime_environment == "prod"
    not input.skill.signature_verified
}

deny contains {
    "code": "SCOPE_NOT_ALLOWED",
    "message": sprintf("scope %q is not allowed for trust tier %q", [scope, input.skill.publisher_trust_tier]),
    "scope": scope,
} if {
    input.runtime_environment == "prod"
    some scope in input.skill.requested_scopes
    not scope_allowed(scope)
}

warn contains {
    "code": "SIGNATURE_SOFT_FLAG",
    "message": "non-production environment accepted an unverified signature",
} if {
    input.runtime_environment != "prod"
    not input.skill.signature_verified
}

warn contains {
    "code": "SCOPE_SOFT_FLAG",
    "message": sprintf("non-production environment accepted scope %q outside the tier allowlist", [scope]),
    "scope": scope,
} if {
    input.runtime_environment != "prod"
    some scope in input.skill.requested_scopes
    not scope_allowed(scope)
}

allow if count(deny) == 0

outcome := "allow" if allow else := "deny"

enforcement_mode := "enforce" if input.runtime_environment == "prod" else := "warn"

decision := {
    "allow": allow,
    "enforcement_mode": enforcement_mode,
    "outcome": outcome,
    "policy_version": policy_version,
    "reasons": [reason | some reason in deny],
    "warnings": [warning | some warning in warn],
}</code></code></pre><p>OPA gives no special meaning to rules named <code>allow</code> and <code>deny</code>. The policy defines how they work together. The <a href="https://www.openpolicyagent.org/docs/policy-language">OPA policy guide</a> explains complete definitions and defaults. <code>default allow := false</code> ensures that the decision stays defined when no allow rule matches.</p><p>Production enforces team membership, known trust tiers, verified signatures, and scope allowlists. Development changes signature and scope findings to warnings. Team isolation remains enforced because development environments can also be shared.</p><p>The decision returns reason objects. A full Rego execution trace contains more detail than operators need for a normal authorization event. <code>SCOPE_NOT_ALLOWED</code> is a stable value for logs, tests, dashboards, and incident reviews.</p><p>OPA server mode can emit <a href="https://www.openpolicyagent.org/docs/management-decision-logs">native decision logs with decision IDs</a>. Embedded Wasm has no OPA server, so Fernbank creates an evaluation ID and logs the policy version and reason codes.</p><p>Test the policy source before compiling it. Create <code>src/test/resources/policies/skill-admission_test.rego</code>:</p><pre><code><code>package fernbank.admission

import rego.v1

internal_manifest := {
    "skill_id": "docs_generate",
    "publisher": "internal:docs-platform",
    "publisher_trust_tier": "internal-verified",
    "signature_verified": true,
    "requested_scopes": ["context:read", "filesystem:write"],
    "declared_capabilities": ["document-generation"],
    "allowed_teams": ["content", "platform"],
}

third_party_manifest := {
    "skill_id": "pptx_export",
    "publisher": "third-party:acme-skills",
    "publisher_trust_tier": "third-party-unverified",
    "signature_verified": true,
    "requested_scopes": ["filesystem:write", "network:egress", "context:read"],
    "declared_capabilities": ["document-generation"],
    "allowed_teams": ["content"],
}

unsigned_manifest := {
    "skill_id": "unsigned_status",
    "publisher": "internal:ops-lab",
    "publisher_trust_tier": "internal-unverified",
    "signature_verified": false,
    "requested_scopes": ["context:read"],
    "declared_capabilities": ["status-reporting"],
    "allowed_teams": ["platform"],
}

content_subject := {
    "agent_id": "fern-assistant",
    "session_id": "session-42",
    "team": "content",
    "user_id": "alice",
}

platform_subject := {
    "agent_id": "fern-assistant",
    "session_id": "session-43",
    "team": "platform",
    "user_id": "bob",
}

test_internal_verified_skill_is_allowed_in_prod if {
    result := decision with input as {
        "action": "mcp:tool:access",
        "runtime_environment": "prod",
        "skill": internal_manifest,
        "subject": content_subject,
    }
    result.allow
    count(result.reasons) == 0
}

test_third_party_write_scope_is_denied_in_prod if {
    result := decision with input as {
        "action": "mcp:tool:access",
        "runtime_environment": "prod",
        "skill": third_party_manifest,
        "subject": content_subject,
    }
    not result.allow
    "SCOPE_NOT_ALLOWED" in {reason.code | some reason in result.reasons}
}

test_missing_signature_is_denied_in_prod if {
    result := decision with input as {
        "action": "mcp:tool:access",
        "runtime_environment": "prod",
        "skill": unsigned_manifest,
        "subject": platform_subject,
    }
    not result.allow
    "PROD_SIGNATURE_REQUIRED" in {reason.code | some reason in result.reasons}
}

test_third_party_write_scope_is_soft_flagged_in_dev if {
    result := decision with input as {
        "action": "mcp:tool:access",
        "runtime_environment": "dev",
        "skill": third_party_manifest,
        "subject": content_subject,
    }
    result.allow
    "SCOPE_SOFT_FLAG" in {warning.code | some warning in result.warnings}
}

test_team_boundary_is_enforced_in_dev if {
    result := decision with input as {
        "action": "mcp:tool:access",
        "runtime_environment": "dev",
        "skill": third_party_manifest,
        "subject": platform_subject,
    }
    not result.allow
    "TEAM_NOT_AUTHORIZED" in {reason.code | some reason in result.reasons}
}</code></code></pre><p>The fifth case checks that team isolation still applies in development. Scope findings become warnings, while the team rule stays enforced.</p><h2><strong>Compile Rego to Wasm With Podman</strong></h2><p>Add <code>scripts/build-policy.sh</code>:</p><pre><code><code>#!/usr/bin/env bash

set -euo pipefail

PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." &amp;&amp; pwd)"
POLICY_DIR="${PROJECT_DIR}/src/main/resources/policies"
TEST_DIR="${PROJECT_DIR}/src/test/resources/policies"
BUNDLE="${PROJECT_DIR}/target/skill-admission-bundle.tar.gz"
EXTRACT_DIR="${PROJECT_DIR}/target/opa-compiled"

mkdir -p "${EXTRACT_DIR}"

podman run --rm \
  -v "${POLICY_DIR}:/policy:ro" \
  -v "${TEST_DIR}:/tests:ro" \
  openpolicyagent/opa:1.17.0 \
  test /policy /tests -v

podman run --rm \
  -v "${PROJECT_DIR}:/workspace" \
  -w /workspace \
  openpolicyagent/opa:1.17.0 \
  build -t wasm \
  -e fernbank/admission/decision \
  -o target/skill-admission-bundle.tar.gz \
  src/main/resources/policies/skill-admission.rego

tar -xzf "${BUNDLE}" -C "${EXTRACT_DIR}"
install -m 0644 "${EXTRACT_DIR}/policy.wasm" "${POLICY_DIR}/skill-admission.wasm"

echo "Wrote ${POLICY_DIR}/skill-admission.wasm"</code></code></pre><p>Make it executable and run it:</p><pre><code><code>chmod +x scripts/build-policy.sh
./scripts/build-policy.sh</code></code></pre><p>The five Rego tests finish with:</p><pre><code><code>PASS: 5/5
Wrote .../src/main/resources/policies/skill-admission.wasm</code></code></pre><p>The compiled module becomes part of the application artifact. Updating the policy therefore requires a new build and deployment.</p><p>If the security team needs to deploy policy independently, use an OPA service or bundle distribution. The remote decision must happen outside this event-loop filter. That design needs a cache or an asynchronous MCP interception API because a blocking network call inside <code>ToolFilter</code> would block the event loop.</p><h2><strong>Evaluate OPA In Process</strong></h2><p>Map the decision with two records:</p><pre><code><code>package com.themainthread.fernbank;

public record PolicyReason(String code, String message, String scope) {
}</code></code></pre><pre><code><code>package com.themainthread.fernbank;

import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;

public record PolicyDecision(
        boolean allow,
        String outcome,
        @JsonProperty("policy_version") String policyVersion,
        @JsonProperty("enforcement_mode") String enforcementMode,
        List&lt;PolicyReason&gt; reasons,
        List&lt;PolicyReason&gt; warnings) {

    public static PolicyDecision evaluationFailure(String message) {
        return new PolicyDecision(
                false,
                "deny",
                "unavailable",
                "fail-closed",
                List.of(new PolicyReason("POLICY_EVALUATION_FAILED", message, null)),
                List.of());
    }
}</code></code></pre><p><code>OpaPolicyEvaluator</code> loads the module once and evaluates the JSON input synchronously:</p><pre><code><code>package com.themainthread.fernbank;

import java.io.IOException;
import java.io.InputStream;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.styra.opa.wasm.OpaPolicy;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class OpaPolicyEvaluator {

    private static final String POLICY_PATH = "/policies/skill-admission.wasm";

    private final ObjectMapper objectMapper;
    private OpaPolicy policy;

    OpaPolicyEvaluator(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @PostConstruct
    void loadPolicy() {
        InputStream stream = OpaPolicyEvaluator.class.getResourceAsStream(POLICY_PATH);
        if (stream == null) {
            throw new IllegalStateException("OPA policy not found: " + POLICY_PATH);
        }
        policy = OpaPolicy.builder().withPolicy(stream).build();
    }

    public PolicyDecision evaluate(AdmissionInput input) {
        try {
            String resultJson = policy.evaluate(objectMapper.writeValueAsString(input));
            JsonNode result = objectMapper.readTree(resultJson).path(0).path("result");
            if (result.isMissingNode() || result.isNull()) {
                throw new IllegalStateException("OPA returned no decision");
            }
            return objectMapper.treeToValue(result, PolicyDecision.class);
        } catch (IOException | RuntimeException e) {
            throw new IllegalStateException("OPA policy evaluation failed", e);
        }
    }
}</code></code></pre><p>If the Wasm file is missing, the application fails during startup. If OPA returns a malformed result, the evaluator throws an exception. The filter catches that exception and denies access. This is required because the extension ignores filter exceptions.</p><h2><strong>Gate MCP Tools With OPA</strong></h2><p>Fernbank exposes three demo tools. Each tool name matches its catalog manifest ID:</p><pre><code><code>package com.themainthread.fernbank;

import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class FernbankTools {

    @Tool(
            name = "docs_generate",
            description = "Generate internal documentation from approved project context.",
            annotations = @Tool.Annotations(
                    title = "Documentation Generator",
                    readOnlyHint = false,
                    destructiveHint = false,
                    idempotentHint = true,
                    openWorldHint = false))
    String generateDocs(@ToolArg(description = "Documentation topic") String topic) {
        return "Generated documentation for: " + topic;
    }

    @Tool(
            name = "pptx_export",
            description = "Export a presentation through the Acme third-party renderer.",
            annotations = @Tool.Annotations(
                    title = "PPTX Exporter",
                    readOnlyHint = false,
                    destructiveHint = false,
                    idempotentHint = true,
                    openWorldHint = true))
    String exportPresentation(@ToolArg(description = "Presentation title") String title) {
        return "Exported presentation: " + title;
    }

    @Tool(
            name = "unsigned_status",
            description = "Read deployment status through an unsigned internal lab skill.",
            annotations = @Tool.Annotations(
                    title = "Unsigned Status Reader",
                    readOnlyHint = true,
                    destructiveHint = false,
                    idempotentHint = true,
                    openWorldHint = false))
    String readStatus(@ToolArg(description = "Service name") String service) {
        return service + " is healthy";
    }
}</code></code></pre><p><code>OpaToolFilter</code> combines the MCP request, caller data, and skill manifest. The demo reads identity fields from HTTP headers to keep setup small. Caller-supplied headers do not prove identity. Production code should read these values from <code>SecurityIdentity</code> after OpenID Connect (OIDC) verification.</p><pre><code><code>package com.themainthread.fernbank;

import io.quarkiverse.mcp.server.FilterContext;
import io.quarkiverse.mcp.server.ToolFilter;
import io.quarkiverse.mcp.server.ToolManager.ToolInfo;
import io.vertx.core.http.HttpServerRequest;

import jakarta.inject.Singleton;

import org.jboss.logging.Logger;

@Singleton
public class OpaToolFilter implements ToolFilter {

    private static final Logger LOG = Logger.getLogger(OpaToolFilter.class);

    private final HttpServerRequest request;
    private final SkillCatalog catalog;
    private final OpaPolicyEvaluator policyEvaluator;
    private final DecisionAudit audit;
    private final FernbankConfig config;

    OpaToolFilter(
            HttpServerRequest request,
            SkillCatalog catalog,
            OpaPolicyEvaluator policyEvaluator,
            DecisionAudit audit,
            FernbankConfig config) {
        this.request = request;
        this.catalog = catalog;
        this.policyEvaluator = policyEvaluator;
        this.audit = audit;
        this.config = config;
    }

    @Override
    public boolean test(ToolInfo tool, FilterContext context) {
        SkillManifest manifest = catalog.find(tool.name()).orElse(null);
        if (manifest == null) {
            LOG.errorf("No skill manifest found for MCP tool %s; denying access", tool.name());
            return false;
        }

        SubjectContext subject = new SubjectContext(
                header("X-Fernbank-User", "anonymous"),
                header("X-Fernbank-Agent", context.connection().initialRequest().implementation().name()),
                header("X-Fernbank-Session", "unknown"),
                header("X-Fernbank-Team", "none"));
        AdmissionInput input = new AdmissionInput(
                subject,
                manifest,
                config.runtimeEnvironment(),
                "mcp:tool:access");

        PolicyDecision decision;
        try {
            decision = policyEvaluator.evaluate(input);
        } catch (RuntimeException e) {
            LOG.errorf(e, "OPA evaluation failed for tool %s; denying access", tool.name());
            decision = PolicyDecision.evaluationFailure(e.getMessage());
        }
        audit.record(input, decision);
        return decision.allow();
    }

    private String header(String name, String defaultValue) {
        String value = request.getHeader(name);
        return value == null || value.isBlank() ? defaultValue : value;
    }
}</code></code></pre><p>Version 1.13.1 uses <code>initialRequest().implementation().name()</code>. An older documentation example uses <code>clientInfo().name()</code>, which does not compile with this version. Follow the API in the 1.13.1 artifact here.</p><p>What happens to <code>pptx_export</code> in production? Its signature is valid, and the team matches. The tool still disappears because <code>filesystem:write</code> and <code>network:egress</code> are outside the <code>third-party-unverified</code> allowlist. OPA returns one <code>SCOPE_NOT_ALLOWED</code> reason for each scope.</p><h2><strong>Configure Enforcement by Environment</strong></h2><p>Create <code>src/main/resources/application.properties</code>:</p><pre><code><code>fernbank.runtime-environment=prod
%dev.fernbank.runtime-environment=dev
%test.fernbank.runtime-environment=prod

quarkus.http.cors.enabled=true
quarkus.http.cors.origins=http://localhost:6274

%dev.quarkus.mcp.server.traffic-logging.enabled=true
%dev.quarkus.mcp.server.traffic-logging.text-limit=1000</code></code></pre><p>Map the Fernbank property with typed configuration:</p><pre><code><code>package com.themainthread.fernbank;

import io.smallrye.config.ConfigMapping;

@ConfigMapping(prefix = "fernbank")
public interface FernbankConfig {

    String runtimeEnvironment();
}</code></code></pre><p>The server configuration sets the environment. The request has no header that can change production enforcement to warning mode. Tests use production mode and verify the deny path.</p><p>The CORS origin matches a local MCP Inspector setup. Replace it with the trusted origin for your client. Traffic logs can contain tool arguments, so this example enables them only in the development profile.</p><h2><strong>Record Every Decision</strong></h2><p>Fernbank keeps the last 100 decisions in memory and logs every record as JSON. A denied exporter produces this record:</p><pre><code><code>{
  "evaluationId": "c1f10476-26e8-4b72-8df0-c7208457d6a1",
  "evaluatedAt": "2026-07-12T03:48:52.158438Z",
  "userId": "alice",
  "agentId": "fern-assistant",
  "sessionId": "session-42",
  "team": "content",
  "skillId": "pptx_export",
  "publisher": "third-party:acme-skills",
  "publisherTrustTier": "third-party-unverified",
  "signatureVerified": true,
  "requestedScopes": ["filesystem:write", "network:egress", "context:read"],
  "declaredCapabilities": ["document-generation"],
  "runtimeEnvironment": "prod",
  "allow": false,
  "outcome": "deny",
  "enforcementMode": "enforce",
  "policyVersion": "2026-07-12",
  "reasons": [
    {
      "code": "SCOPE_NOT_ALLOWED",
      "message": "scope \"filesystem:write\" is not allowed for trust tier \"third-party-unverified\"",
      "scope": "filesystem:write"
    },
    {
      "code": "SCOPE_NOT_ALLOWED",
      "message": "scope \"network:egress\" is not allowed for trust tier \"third-party-unverified\"",
      "scope": "network:egress"
    }
  ],
  "warnings": [],
  "reasonCodes": ["SCOPE_NOT_ALLOWED", "SCOPE_NOT_ALLOWED"],
  "warningCodes": []
}</code></code></pre><p>The audit record stores caller data, skill data, policy identity, and the result:</p><pre><code><code>package com.themainthread.fernbank;

import java.time.Instant;
import java.util.List;

public record AdmissionAuditRecord(
        String evaluationId,
        Instant evaluatedAt,
        String userId,
        String agentId,
        String sessionId,
        String team,
        String skillId,
        String publisher,
        String publisherTrustTier,
        boolean signatureVerified,
        List&lt;String&gt; requestedScopes,
        List&lt;String&gt; declaredCapabilities,
        String runtimeEnvironment,
        boolean allow,
        String outcome,
        String enforcementMode,
        String policyVersion,
        List&lt;PolicyReason&gt; reasons,
        List&lt;PolicyReason&gt; warnings,
        List&lt;String&gt; reasonCodes,
        List&lt;String&gt; warningCodes) {
}</code></code></pre><p><code>DecisionAudit</code> limits the local buffer to 100 entries. It also sorts reason codes because Rego produces sets and does not guarantee their order:</p><pre><code><code>package com.themainthread.fernbank;

import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.UUID;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.enterprise.context.ApplicationScoped;

import org.jboss.logging.Logger;

@ApplicationScoped
public class DecisionAudit {

    private static final Logger LOG = Logger.getLogger(DecisionAudit.class);
    private static final int CAPACITY = 100;

    private final ObjectMapper objectMapper;
    private final Deque&lt;AdmissionAuditRecord&gt; records = new ArrayDeque&lt;&gt;(CAPACITY);

    DecisionAudit(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public synchronized AdmissionAuditRecord record(AdmissionInput input, PolicyDecision decision) {
        AdmissionAuditRecord record = new AdmissionAuditRecord(
                UUID.randomUUID().toString(),
                Instant.now(),
                input.subject().userId(),
                input.subject().agentId(),
                input.subject().sessionId(),
                input.subject().team(),
                input.skill().skillId(),
                input.skill().publisher(),
                input.skill().publisherTrustTier(),
                input.skill().signatureVerified(),
                List.copyOf(input.skill().requestedScopes()),
                List.copyOf(input.skill().declaredCapabilities()),
                input.runtimeEnvironment(),
                decision.allow(),
                decision.outcome(),
                decision.enforcementMode(),
                decision.policyVersion(),
                List.copyOf(decision.reasons()),
                List.copyOf(decision.warnings()),
                codes(decision.reasons()),
                codes(decision.warnings()));

        if (records.size() == CAPACITY) {
            records.removeFirst();
        }
        records.addLast(record);
        log(record);
        return record;
    }

    public synchronized List&lt;AdmissionAuditRecord&gt; recent(int limit) {
        int safeLimit = Math.max(1, Math.min(limit, CAPACITY));
        List&lt;AdmissionAuditRecord&gt; snapshot = new ArrayList&lt;&gt;(records);
        int fromIndex = Math.max(0, snapshot.size() - safeLimit);
        return List.copyOf(snapshot.subList(fromIndex, snapshot.size()));
    }

    private List&lt;String&gt; codes(List&lt;PolicyReason&gt; reasons) {
        return reasons.stream().map(PolicyReason::code).sorted().toList();
    }

    private void log(AdmissionAuditRecord record) {
        try {
            LOG.infof("opa_decision=%s", objectMapper.writeValueAsString(record));
        } catch (JsonProcessingException e) {
            LOG.warnf(e, "Could not serialize OPA decision %s", record.evaluationId());
        }
    }
}</code></code></pre><p>Expose the buffer through <code>DecisionResource.java</code>:</p><pre><code><code>package com.themainthread.fernbank;

import java.util.List;

import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;

@Path("/api/decisions")
@Produces(MediaType.APPLICATION_JSON)
public class DecisionResource {

    private final DecisionAudit audit;

    DecisionResource(DecisionAudit audit) {
        this.audit = audit;
    }

    @GET
    public List&lt;AdmissionAuditRecord&gt; recent(@QueryParam("limit") @DefaultValue("20") int limit) {
        return audit.recent(limit);
    }
}</code></code></pre><p>The in-memory buffer keeps the example self-contained. Production should send these records to an append-only audit store with retention, access control, and a policy-bundle digest. The application-generated UUID connects related logs. It is different from OPA&#8217;s native <code>decision_id</code> because the Wasm path has no OPA server.</p><h2><strong>Test Tool Visibility and Direct Calls</strong></h2><p>The <a href="https://docs.quarkiverse.io/quarkus-mcp-server/1.13.x/guides-testing.html">McpAssured test client</a> speaks Streamable HTTP and supports custom request headers. The test lists the visible tools, calls a hidden tool by name, and checks the audit endpoint:</p><pre><code><code>package com.themainthread.fernbank;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Map;
import java.util.UUID;

import org.junit.jupiter.api.Test;

import io.quarkiverse.mcp.server.test.McpAssured;
import io.quarkiverse.mcp.server.test.McpAssured.McpStreamableTestClient;
import io.quarkus.test.junit.QuarkusTest;
import io.vertx.core.MultiMap;

@QuarkusTest
class ToolExposureTest {

    @Test
    void productionClientOnlySeesAdmittedTools() {
        McpStreamableTestClient client = clientFor("alice", "content");
        try {
            client.when()
                    .toolsList(page -&gt; {
                        assertEquals(1, page.size());
                        assertNotNull(page.findByName("docs_generate"));
                        assertFalse(page.tools().stream().anyMatch(tool -&gt; tool.name().equals("pptx_export")));
                        assertFalse(page.tools().stream().anyMatch(tool -&gt; tool.name().equals("unsigned_status")));
                    })
                    .thenAssertResults();

            client.when()
                    .toolsCall("pptx_export")
                    .withArguments(Map.of("title", "Quarterly review"))
                    .withErrorAssert(error -&gt; assertTrue(error.message().contains("pptx_export")))
                    .send()
                    .thenAssertResults();

            given()
                    .queryParam("limit", 20)
                    .when().get("/api/decisions")
                    .then()
                    .statusCode(200)
                    .body("skillId", hasItem("pptx_export"))
                    .body("reasonCodes.flatten()", hasItem("SCOPE_NOT_ALLOWED"));
        } finally {
            client.disconnect();
        }
    }

    private McpStreamableTestClient clientFor(String user, String team) {
        return McpAssured.newStreamableClient()
                .setAdditionalHeaders(message -&gt; {
                    MultiMap headers = MultiMap.caseInsensitiveMultiMap();
                    headers.add("X-Fernbank-User", user);
                    headers.add("X-Fernbank-Agent", "fern-assistant");
                    headers.add("X-Fernbank-Session", UUID.randomUUID().toString());
                    headers.add("X-Fernbank-Team", team);
                    return headers;
                })
                .build()
                .connect();
    }
}</code></code></pre><p>Run the suite:</p><pre><code><code>./mvnw test</code></code></pre><p>Expected result:</p><pre><code><code>Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS</code></code></pre><p>The first assertion checks client-specific exposure. The second checks that a direct call to a hidden tool also fails. The final assertion checks that the policy decision reached the audit log.</p><p>Four other tests check the policy result directly:</p><ul><li><p>Internal, verified, narrow scopes in production: allow</p></li><li><p>Third-party, unverified tier with filesystem and network scopes in production: deny with <code>SCOPE_NOT_ALLOWED</code></p></li><li><p>Missing signature in production: deny with <code>PROD_SIGNATURE_REQUIRED</code></p></li><li><p>The third-party exporter in development: allow with <code>SCOPE_SOFT_FLAG</code></p></li></ul><h2><strong>Run Fernbank</strong></h2><p>Start Quarkus:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>The Streamable HTTP endpoint is <code>http://localhost:8080/mcp</code>. In dev mode, scope violations produce warnings. A content-team client therefore sees <code>docs_generate</code> and <code>pptx_export</code>. It does not see <code>unsigned_status</code> because that tool only allows the platform team.</p><p>To inspect the production deny behavior in dev mode, override the Fernbank environment:</p><pre><code><code>./mvnw quarkus:dev -Dfernbank.runtime-environment=prod</code></code></pre><p>After an MCP client lists tools, inspect the recent decisions:</p><pre><code><code>curl -s http://localhost:8080/api/decisions | jq</code></code></pre><p>Rego reason collections are sets, so their order may change. Consumers should use the stable <code>code</code> values and ignore array order.</p><h2><strong>Before Production</strong></h2><p>Three parts need to change before production.</p><p><strong>Verify identity.</strong> Replace the <code>X-Fernbank-*</code> headers with an OIDC bearer token and values from Quarkus <code>SecurityIdentity</code>. A reverse proxy may add trusted headers after authentication. The application must still prevent callers from setting those headers directly.</p><p><strong>Store audit records outside the process.</strong> Send each record to an append-only system. Include a digest or revision for the policy bundle, manifest, and deployed tool artifact. The date in <code>policy_version</code> is easy to read, while content hashes identify the exact files that ran. Remove <code>/api/decisions</code> or protect it with an operator role because the response contains user, session, publisher, and scope data.</p><p><strong>Choose how policy is deployed.</strong> Embedded Wasm keeps evaluation local and gives us a simple failure mode. It also deploys policy with the application. A central OPA service supports independent policy rollout and native decision logs. The current synchronous <code>ToolFilter</code> cannot wait for that service without blocking the event loop. Use precomputed permissions, a local bundle agent, or an asynchronous interception point.</p><p>Tool annotations such as <code>readOnlyHint</code> help MCP clients understand intent. They do not enforce authorization. Fernbank evaluates the signed catalog manifest and caller context, then filters the MCP protocol. The tool implementation still needs normal authorization at the business boundary because another endpoint or an internal Java call may reach the same operation.</p><h2><strong>Conclusion</strong></h2><p>Fernbank evaluates skill metadata and caller identity before Quarkus exposes or invokes an MCP tool. The model has no role in that decision. A denial includes the policy version, caller context, and reason code that an operator can inspect later.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p>]]></content:encoded></item><item><title><![CDATA[Spec-Driven Development Needs an Exit Strategy]]></title><description><![CDATA[AI coding teams need small change briefs, native engineering artifacts, and enough judgment to stop Markdown specifications becoming a second codebase.]]></description><link>https://www.the-main-thread.com/p/spec-driven-development-exit-strategy</link><guid isPermaLink="false">https://www.the-main-thread.com/p/spec-driven-development-exit-strategy</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Wed, 15 Jul 2026 06:08:14 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/1f3d4cbd-0392-4010-8fd4-127100bb6fb2_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Open a repository after six months of spec-driven agent work and you may find a second system sitting next to the code. Requirements, research notes, high-level designs, low-level designs, implementation plans, task lists, review reports, and a growing stack of Markdown that explains what the code is supposed to mean.</p><p>The code changed last Tuesday. Half the documents did not.</p><p>I understand how teams get there. Agents produce code quickly, so we try to move more thought in front of implementation. We ask for clearer requirements, more design, and stronger acceptance criteria. Then we turn that into a workflow. Before long, every change has fifteen stages and enough generated prose to use most of the context window before the agent reads the relevant class.</p><p>That is not a specification system. It is a second codebase with weaker tooling.</p><p>We still need specifications. The mistake is treating a specification as a permanent natural-language copy of the software. A useful spec describes the next change, exposes the decisions that matter, and gives us something to verify. After the change ships, most of it should disappear.</p><p>What remains should move into the artifacts software teams already know how to maintain: code, schemas, tests, policies, configuration, telemetry, and a small number of decision records.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!vSMT!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!vSMT!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 424w, https://substackcdn.com/image/fetch/$s_!vSMT!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 848w, https://substackcdn.com/image/fetch/$s_!vSMT!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 1272w, https://substackcdn.com/image/fetch/$s_!vSMT!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!vSMT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png" width="1456" height="663" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:663,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:193644,&quot;alt&quot;:&quot;A change specification should be temporary by default. After implementation, durable information moves into code, schemas, tests, policies, and operational signals. The rest leaves the active context.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/206533941?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="A change specification should be temporary by default. After implementation, durable information moves into code, schemas, tests, policies, and operational signals. The rest leaves the active context." title="A change specification should be temporary by default. After implementation, durable information moves into code, schemas, tests, policies, and operational signals. The rest leaves the active context." srcset="https://substackcdn.com/image/fetch/$s_!vSMT!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 424w, https://substackcdn.com/image/fetch/$s_!vSMT!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 848w, https://substackcdn.com/image/fetch/$s_!vSMT!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 1272w, https://substackcdn.com/image/fetch/$s_!vSMT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb11b3dbe-f277-4298-8d51-6710b03db04c_2540x1156.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2><strong>Code becomes the fact</strong></h2><p>Code is actual behavior. Once that behavior reaches production, users and connected systems start to depend on it. A mistake can become an observed contract because it has behaved the same way for three years. When the system fails, the running behavior matters more than the design document that described something else.</p><p>For existing behavior, start with the code and the production system. Reading a natural-language summary instead is rarely an improvement.</p><p>The code still cannot decide what the next version should do. It does not know which behavior must stay stable, which policy changed, which workaround can disappear, or which user outcome matters most. A repository can show that an account-closing function deletes a record. It may not show that regulation requires seven years of retention or that another company consumes an undocumented export every night.</p><p>That context has to come from somewhere else. But it does not follow that we need a permanent prose description of the whole system.</p><p>We need enough context to decide the delta: the difference between what exists and what should exist next.</p><h2><strong>Specs are for the delta</strong></h2><p>A change specification earns its cost when it helps a team decide and review that delta. It should name the outcome, non-goals, constraints that differ from current behavior, and evidence required for acceptance. A technical design belongs there when the change crosses an architectural boundary or contains a decision that will be expensive to reverse.</p><p>That is enough for many changes:</p><ul><li><p>the intended outcome and non-goals;</p></li><li><p>known unknowns and decisions that need human judgment;</p></li><li><p>affected system boundaries and authoritative interface artifacts;</p></li><li><p>functional and non-functional constraints that differ from today;</p></li><li><p>acceptance evidence proportionate to the risk.</p></li></ul><p>I prefer calling this a change brief. &#8220;Specification&#8221; now carries too much baggage. It suggests a complete description, and completeness is exactly where these methods become expensive.</p><p>Teams have tried exhaustive specification before. They produced requirement documents, high-level designs, low-level designs, and architecture records for every decision. The collection grew until finding the right document cost more than reading the code. Humans stopped looking. The documentation stayed complete in the administrative sense and became useless in the engineering sense.</p><p>Agents can recreate the same failure much faster. A long workflow produces research, requirements, design, planning, and review artifacts on demand. Every stage makes the process look more controlled. It also creates more material to read, reconcile, retrieve, and keep current.</p><p>Research notes, prototypes, and design records should be added when uncertainty justifies them. They resolve a specific problem. They should not become required stages for every pull request.</p><h2><strong>The map will always be incomplete</strong></h2><p>A prompt, ticket, or spec is a map of the work. The territory includes the codebase, users, runtime environment, historical constraints, connected systems, and decisions that nobody wrote down.</p><p>Real work keeps producing new information. The agent reads a module and finds an unexpected dependency. A prototype exposes a usability problem. A test reveals an undocumented edge case. Production data contradicts the design assumption.</p><p>A recent field guide on <a href="https://x.com/trq212/status/2073100352921215386">finding unknowns in agent work</a> makes this point well. Some unknowns are visible at the start. Others appear only when we inspect references, build prototypes, or compare a result with judgment we could not express beforehand.</p><p>So discovery has to stay open:</p><p><strong>Before implementation</strong>, inspect the current system, identify decisions that could change the architecture or user experience, and build a cheap prototype when preferences are hard to state.</p><p><strong>During implementation</strong>, record meaningful deviations and stop when a new unknown changes the risk or direction of the work.</p><p><strong>After implementation</strong>, read the code, run the checks, and compare the result with the original intent.</p><p>The spec participates in that loop. It cannot complete the loop before coding begins.</p><h2><strong>Keep durable facts in their native form</strong></h2><p>The phrase &#8220;promote durable constraints&#8221; needs care. It can sound like an instruction to write permanent Markdown. That would recreate the problem.</p><p>Software engineering already has better homes for most durable facts:</p><ul><li><p>API shape and compatibility belong in OpenAPI, AsyncAPI, protocol schemas, types, and compatibility tests.</p></li><li><p>Data invariants belong in types, database constraints, validation, and migration checks.</p></li><li><p>Security rules belong in access policy, static analysis, dependency policy, and runtime enforcement.</p></li><li><p>Architecture boundaries belong in module structure, dependency rules, and focused architecture tests.</p></li><li><p>Reliability requirements belong in load tests, service objectives, telemetry, and alerts.</p></li><li><p>Release rules belong in continuous integration and deployment policy.</p></li></ul><p>These artifacts participate in delivery. A failed schema check or alert demands attention. A paragraph in an old design directory usually does not.</p><p>Natural language still has a place. Business policy, trade-offs, and architectural rationale do not always fit into an executable artifact. Keep that prose short, owned, and close to the thing it explains. An architecture decision record is worth keeping when a future team might otherwise repeat an expensive investigation. Recording every local choice just hides the few decisions that matter.</p><p>Ask which fact must survive and what its authoritative form should be.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!LaLB!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!LaLB!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 424w, https://substackcdn.com/image/fetch/$s_!LaLB!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 848w, https://substackcdn.com/image/fetch/$s_!LaLB!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 1272w, https://substackcdn.com/image/fetch/$s_!LaLB!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!LaLB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png" width="1456" height="915" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:915,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:284774,&quot;alt&quot;:&quot;Briefs and design notes support the change. Native engineering artifacts carry the constraints and evidence that remain relevant after release.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/206533941?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Briefs and design notes support the change. Native engineering artifacts carry the constraints and evidence that remain relevant after release." title="Briefs and design notes support the change. Native engineering artifacts carry the constraints and evidence that remain relevant after release." srcset="https://substackcdn.com/image/fetch/$s_!LaLB!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 424w, https://substackcdn.com/image/fetch/$s_!LaLB!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 848w, https://substackcdn.com/image/fetch/$s_!LaLB!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 1272w, https://substackcdn.com/image/fetch/$s_!LaLB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4089fb25-4193-4556-b3c9-0daa44ef9e98_2540x1596.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2><strong>Judgment belongs in the workflow</strong></h2><p>Heavy specification methods try to control quality by prescribing the path. Every change produces the same documents, reviews, and test categories.</p><p>That consistency can spend attention on low-risk work while hiding the judgment needed for the hard parts. A copy change and a payment-flow change do not need the same process or testing strategy.</p><p><a href="https://simonwillison.net/2026/Jul/3/judgement/">Simon Willison describes</a> a simpler instruction: let the coding agent apply judgment about how to perform the work instead of spelling out every procedural branch. I think the advice is right.</p><p>Judgment does not mean unconstrained autonomy. The team still defines the outcome, safety boundaries, ownership, and acceptance authority. The agent can choose tactics inside those boundaries. It should surface uncertainty and ask for a decision when the consequences exceed its authority.</p><p>The workflow can then follow the risk:</p><ul><li><p>a small, familiar change can move from a short brief to implementation and review;</p></li><li><p>unfamiliar code calls for factual research before design;</p></li><li><p>unclear user experience calls for prototypes and comparison;</p></li><li><p>an architectural change needs explicit human alignment;</p></li><li><p>high-consequence behavior needs stronger independent evidence and approval.</p></li></ul><p>The process expands when the work demands it. Starting every change at maximum weight wastes time and context.</p><h2><strong>Context is an engineering budget</strong></h2><p>Large specifications cost more than authoring and maintenance time. They compete with the code and evidence the agent needs for the current decision.</p><p>Every requirement, design note, repository instruction, and tool definition consumes part of a limited working context. Important rules become harder to apply when stale and duplicated material surrounds them. A spec that leaves too little room for the repository defeats its own purpose.</p><p>Progressive disclosure is a better fit. Give the agent a small map, stable rules that apply broadly, and pointers to deeper material. Retrieve the relevant schema, decision, or module guide when the work crosses that boundary.</p><p>A concise <code>AGENTS.md</code> can document build commands, repository layout, and architectural boundaries. It should not narrate every class or repeat API documentation. The file helps humans for the same reason: it tells them where to look without pretending to replace what they will find.</p><p>Experience with Research-Plan-Implement shows the cost of getting this wrong. The original workflow moved human review before implementation, but teams ended up with large prompts and plans that could reach 1,000 lines. Engineers reviewed the plan while treating generated code almost like compiler output. That approach worked better on simple greenfield tasks than on complex changes to existing systems.</p><p>In <a href="https://www.youtube.com/watch?v=YwZR6tc7qYg">Everything We Got Wrong About Research-Plan-Implement</a>, Dexter Horthy retracts that position. Teams shipped more code and then spent much of the gain cleaning up earlier output. The implementation could also diverge from the reviewed plan, which forced engineers to reconstruct what happened from the code anyway.</p><p>The revised workflow uses smaller contexts for factual research, design alignment, structure, implementation, and review. The correction is simple: research and design create leverage, but engineers still read and own the code.</p><h2><strong>Modernization makes this obvious</strong></h2><p>A mature application contains several kinds of behavior in the same codebase. Some logic represents durable business policy. Some implements a published interface. Some exists because an old platform imposed a technical limit. Some came from an incident fix whose context is gone. Some is simply a defect that survived long enough to look intentional.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!hAya!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!hAya!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 424w, https://substackcdn.com/image/fetch/$s_!hAya!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 848w, https://substackcdn.com/image/fetch/$s_!hAya!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 1272w, https://substackcdn.com/image/fetch/$s_!hAya!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!hAya!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png" width="1456" height="867" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:867,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:267966,&quot;alt&quot;:&quot;Legacy code is evidence of accumulated decisions. Modernization requires a separate judgment about which behavior to preserve, verify, redesign, or remove.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/206533941?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Legacy code is evidence of accumulated decisions. Modernization requires a separate judgment about which behavior to preserve, verify, redesign, or remove." title="Legacy code is evidence of accumulated decisions. Modernization requires a separate judgment about which behavior to preserve, verify, redesign, or remove." srcset="https://substackcdn.com/image/fetch/$s_!hAya!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 424w, https://substackcdn.com/image/fetch/$s_!hAya!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 848w, https://substackcdn.com/image/fetch/$s_!hAya!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 1272w, https://substackcdn.com/image/fetch/$s_!hAya!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a06ced2-7fb3-4d93-88e6-585ffda664f2_2540x1512.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>An agent that treats all code as the target specification can translate those layers faithfully into a new language or architecture. You get a cleaner version of the same debt. Efficient, but not helpful.</p><p>Modernization starts by classifying observed behavior:</p><ul><li><p><strong>Preserve</strong> durable business invariants and externally required behavior.</p></li><li><p><strong>Verify</strong> behavior that appears active but lacks clear ownership or evidence.</p></li><li><p><strong>Redesign</strong> logic tied to obsolete architectural constraints.</p></li><li><p><strong>Remove</strong> dead paths, duplicated logic, and confirmed defects.</p></li></ul><p>Code analysis reveals dependencies and current behavior. Runtime data shows which paths are still used. Tests establish a baseline. Business, architecture, risk, and operational context decide which behavior belongs in the target system.</p><p>The code is the starting evidence because it is the behavior we have. The change brief is temporary because its job is to decide the delta. The new implementation and its native checks become the next durable state.</p><h2><strong>Small specs still need real evidence</strong></h2><p>Keeping specifications small does not mean returning to a loose prompt followed by hopeful review.</p><p>An agent can turn an underspecified request into a coherent implementation before the missing decisions become obvious. The result may compile, pass the available tests, and look internally consistent. Coherence can hide a business decision that nobody approved.</p><p><a href="https://arxiv.org/abs/2505.07270">Research on repairing ambiguous natural-language requirements</a> found that targeted repairs improved code-generation results, while direct ambiguity resolution often produced irrelevant or inconsistent clarification. <a href="https://arxiv.org/abs/2406.12952">SWT-Bench</a> found that generated tests could filter proposed fixes and double the precision of a software-repair agent.</p><p>Formal specification does not remove review either. <a href="https://arxiv.org/abs/2606.05792">A 2026 evaluation</a> asked 30 models to translate natural-language requirements into TLA+ specifications. The best result reached 26.6% syntactic correctness and 8.6% semantic correctness. Preserving meaning between representations remained the hard part.</p><p>These results support focused clarification and independent checks. They do not support generating the longest spec or letting one reasoning path define the requirement, implementation, and evidence without challenge.</p><p>The amount of independence should follow the risk. A small internal refactor may need ordinary tests and code review. A change to authorization, money movement, or regulated data needs stronger separation, adversarial review, and explicit approval.</p><h2><strong>A lighter operating model</strong></h2><p>The method does not need fifteen stages:</p><ol><li><p>Start from the code and operational evidence that describe the current system.</p></li><li><p>Define the intended delta, important boundaries, and known unknowns.</p></li><li><p>Add research, prototypes, design alignment, or stronger verification where risk requires them.</p></li><li><p>Read and review the implementation, not only the plan.</p></li><li><p>At release, discard temporary reasoning and preserve each surviving fact in its native authoritative artifact.</p></li></ol><p>That is enough structure to guide the work without building a natural-language replica of the software.</p><p>Before implementation, the change brief describes the intended delta. During implementation, it helps people and agents align while new information changes the plan. After release, code and production behavior become the primary evidence of what the system does.</p><p>Durable obligations survive in the forms we already know how to maintain: schemas, tests, policies, configuration, telemetry, and short records for rationale that cannot be encoded elsewhere. Most planning detail should expire.</p><p>The teams that benefit most from coding agents will not produce the most documentation. They will know what to specify, what to discover, what to verify, and what to let go.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><h2></h2>]]></content:encoded></item><item><title><![CDATA[Build Your First Hardened Quarkus Image with Jib and Java 25]]></title><description><![CDATA[The final Quarkus service in this article has no Dockerfile.]]></description><link>https://www.the-main-thread.com/p/quarkus-jib-hummingbird</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-jib-hummingbird</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Mon, 13 Jul 2026 06:08:17 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ba7245ed-3500-4cd8-9634-c86cc7f3ba31_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The final Quarkus service in this article has no Dockerfile. Maven packages the application as a Quarkus fast-jar, Jib turns that distribution into OCI layers, and the Red Hat Hardened OpenJDK 25 image supplies the runtime.</p><p>That short build path still leaves a few decisions with us. The base image decides which operating-system files ship, which user starts the process, whether a shell exists, and how quickly security fixes arrive. The image builder decides how application files are layered and which entrypoint becomes part of the image metadata. A clean vulnerability report means little if the container starts as root or points at a launch script that is missing from the filesystem.</p><p><a href="https://www.redhat.com/en/blog/red-hat-hardened-images">Red Hat Hardened Images</a> is the generally available catalog produced by Project Hummingbird. Its runtime images keep a small software footprint, default to a non-root user where possible, and remove tools that the workload does not need. The <a href="https://hummingbird-project.io/docs/using/overview/">Hummingbird image documentation</a> describes the default variants as distroless: no package manager and no shell.</p><p><a href="https://github.com/GoogleContainerTools/jib">Jib</a> builds Docker and OCI images directly from Java build tools. It separates dependencies from application classes so a code change does not invalidate every layer. Quarkus adds its own integration through <code>quarkus-container-image-jib</code>, which understands the fast-jar layout and constructs the runtime entrypoint for us.</p><h2><strong>What We Build</strong></h2><p>We build a small status service on Quarkus and Java 25. It exposes <code>/status</code> and the SmallRye Health readiness endpoint. Jib places the Quarkus fast-jar on top of <code>registry.access.redhat.com/hi/openjdk:25-runtime</code>, then loads the result into Podman.</p><p>The final checks shows:</p><ul><li><p>The HTTP application and readiness endpoint respond</p></li><li><p>The packaged application runs on the Java 25 Hummingbird runtime</p></li><li><p>The image starts as UID <code>65532</code> with <code>/work</code> as its working directory</p></li><li><p>Starting <code>/bin/sh</code> fails because the runtime image does not contain a shell</p></li></ul><h2><strong>What You Need</strong></h2><p>The application has no external service dependency. Podman is only needed when we build and run the local image.</p><ul><li><p>JDK 25 installed</p></li><li><p>Quarkus CLI </p></li><li><p>Podman 5 or later</p></li><li><p><code>curl</code> and <code>jq</code></p></li><li><p>About &#9749;&#65039;&#9749;&#65039;</p></li><li><p>Optional: Cosign for signature verification</p></li></ul><p>On macOS and Windows, start the Podman virtual machine before the image build:</p><pre><code><code>podman machine start</code></code></pre><h2><strong>Create the Project</strong></h2><p>Create the application:</p><pre><code><code>quarkus create app com.themainthread:hardened-quarkus-jib \
  --extension=quarkus-rest-jackson,quarkus-smallrye-health,quarkus-container-image-jib \
  --java=25 \
  --no-code \
  --no-dockerfiles</code></code></pre><p>Use these extensions:</p><ul><li><p><code>quarkus-rest-jackson</code> provides the JSON REST endpoint</p></li><li><p><code>quarkus-smallrye-health</code> exposes <code>/q/health/ready</code></p></li><li><p><code>quarkus-container-image-jib</code> turns the Quarkus package into an OCI image without reading a Dockerfile</p></li></ul><p>The <a href="https://quarkus.io/extensions/io.quarkus/quarkus-container-image-jib/">Quarkus extension catalog</a> marks <code>quarkus-container-image-jib</code> as preview.</p><p><code>--no-dockerfiles</code> keeps the generated project aligned with the build we use. Quarkus usually scaffolds Dockerfiles with your application. But Jib does not read a Dockerfile, so we don&#8217;t even need it.</p><h2><strong>Add a Runtime Probe</strong></h2><p>The application code stays small because the container is the subject here. Create <code>src/main/java/com/themainthread/hardened/StatusResource.java</code>:</p><pre><code><code>package com.themainthread.hardened;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/status")
@Produces(MediaType.APPLICATION_JSON)
public class StatusResource {

    @GET
    public StatusResponse status() {
        return new StatusResponse(
                "hardened-quarkus-jib",
                "ready",
                Runtime.version().feature());
    }

    public record StatusResponse(String service, String status, int javaFeatureVersion) {
    }
}</code></code></pre><p><code>Runtime.version().feature()</code> gives us a visible check on the JVM. The compiler release and the container runtime are separate settings, so we verify Java 25 twice: <code>@QuarkusTest</code> checks the Maven JVM, and <code>@QuarkusIntegrationTest</code> checks the Hummingbird JVM inside the packaged image.</p><h2><strong>Configure Jib and the Hardened Runtime</strong></h2><p>Replace <code>src/main/resources/application.properties</code> with the following configuration:</p><pre><code><code>quarkus.application.name=hardened-quarkus-jib

quarkus.container-image.group=themainthread
quarkus.container-image.name=hardened-quarkus-jib
quarkus.container-image.registry=localhost
quarkus.container-image.labels."org.opencontainers.image.source"=https://github.com/myfear/the-main-thread/hardened-quarkus-jib
quarkus.container-image.labels."org.opencontainers.image.title"=Hardened Quarkus with Jib

quarkus.jib.base-jvm-image=registry.access.redhat.com/hi/openjdk:25-runtime
quarkus.jib.docker-executable-name=podman
quarkus.jib.jvm-additional-arguments=-XX:MaxRAMPercentage=70.0,-XX:+ExitOnOutOfMemoryError
quarkus.jib.user=65532
quarkus.jib.working-directory=/work
quarkus.jib.use-current-timestamp=false
quarkus.jib.use-current-timestamp-file-modification=false</code></code></pre><p>The container-image properties produce <code>localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT</code> and add OCI source and title labels. The application version supplies the image tag unless you override <code>quarkus.container-image.tag</code>.</p><p><code>quarkus.jib.base-jvm-image</code> selects the Hummingbird OpenJDK 25 runtime variant. It contains the headless runtime. The builder variant adds the extra build tools. The published image is available for Linux on AMD64 and ARM64.</p><p><code>quarkus.jib.docker-executable-name=podman</code> tells Jib where to load a local build. Jib still assembles the image. Podman receives the completed image and then runs it. A registry push uses the registry API directly, so it does not need a local container daemon.</p><p>The Hummingbird OpenJDK runtime declares UID <code>65532</code>. We set the same value on the final image so the runtime contract stays visible in our own metadata. Kubernetes can still assign another arbitrary non-root UID, provided the application files remain readable.</p><p>The Hummingbird Java image uses <code>/home/build</code>, while the <a href="https://quarkus.io/guides/container-image#jib-options">Quarkus Jib configuration</a> defaults to <code>/home/jboss</code> for its standard UBI Java image. <code>/work</code> gives the fast-jar one predictable location. Our application only reads from it, so UID <code>65532</code> does not need write access to the application layers.</p><p>We leave <code>quarkus.jib.jvm-entrypoint</code> unset. For this custom base image, Quarkus constructs a direct Java command that ends with <code>-jar quarkus-run.jar</code>. This matters because the Hummingbird runtime has no shell and does not ship the UBI <code>run-java.sh</code> helper.</p><p><code>-XX:MaxRAMPercentage=70.0</code> caps the maximum heap near 70% of the container memory limit. That leaves headroom for metaspace, thread stacks, direct buffers, native allocations, and the operating-system runtime. <code>-XX:+ExitOnOutOfMemoryError</code> terminates the process after an out-of-memory error so the orchestrator can replace it. Seventy percent is a starting point for this small service. Measure heap and native memory under your own load before copying it into a large application.</p><p>Finally, the timestamp settings make unchanged application layers stable between builds. Podman reports the image creation time as January 1, 1970 with this configuration. Jib uses the Unix epoch here so time alone does not change the digest. The complete image digest can still change when the Hummingbird tag points to a patched base. That is exactly what we want during a security rebuild.</p><h2><strong>Test the Two Runtime Boundaries</strong></h2><p>The generated project already contains the Quarkus JUnit dependency. Add RestAssured to the <code>&lt;dependencies&gt;</code> section in <code>pom.xml</code>:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.rest-assured&lt;/groupId&gt;
    &lt;artifactId&gt;rest-assured&lt;/artifactId&gt;
    &lt;scope&gt;test&lt;/scope&gt;
&lt;/dependency&gt;</code></code></pre><p>The Quarkus platform manages the RestAssured version used by the tests below.</p><p>Add the REST and health test at <code>src/test/java/com/themainthread/hardened/StatusResourceTest.java</code>:</p><pre><code><code>package com.themainthread.hardened;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
class StatusResourceTest {

    @Test
    void returnsRuntimeStatus() {
        given()
                .when().get("/status")
                .then()
                .statusCode(200)
                .body("service", equalTo("hardened-quarkus-jib"))
                .body("status", equalTo("ready"))
                .body("javaFeatureVersion", equalTo(25));
    }

    @Test
    void exposesReadiness() {
        given()
                .when().get("/q/health/ready")
                .then()
                .statusCode(200)
                .body("status", equalTo("UP"));
    }
}</code></code></pre><p>This test checks application behavior on the Maven JVM. Run it now:</p><pre><code><code>./mvnw test</code></code></pre><p>This assertion proves Maven launched the Quarkus test application on Java 25. It will fail early if the project is compiled with one JDK and tested with another.</p><p>Now add <code>src/test/java/com/themainthread/hardened/StatusResourceIT.java</code>:</p><pre><code><code>package com.themainthread.hardened;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
class StatusResourceIT extends StatusResourceTest {

    @Test
    void runsOnHummingbirdJava25Runtime() {
        given()
                .when().get("/status")
                .then()
                .statusCode(200)
                .body("javaFeatureVersion", equalTo(25));
    }
}</code></code></pre><p><code>@QuarkusIntegrationTest</code> runs against the packaged artifact. When the container-image build is enabled, Quarkus starts the resulting image for the test. The Java 25 assertion here proves the base-image choice survived packaging.</p><p>The generated POM sets <code>skipITs</code> to <code>true</code>, so the image build below overrides it.</p><h2><strong>Build the Image and Run the Container Test</strong></h2><p>Build the fast-jar, assemble the image, load it into Podman, and run the integration test:</p><pre><code><code>./mvnw verify \
  -DskipITs=false \
  -Dquarkus.container-image.build=true</code></code></pre><p>The final test summary should contain:</p><pre><code><code>[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS</code></code></pre><p>Jib also warns that the base uses a tag:</p><pre><code><code>[WARNING] Base image 'registry.access.redhat.com/hi/openjdk:25-runtime' does not use a specific image digest - build may not be reproducible</code></code></pre><p>That warning is accurate. The timestamp settings stabilize our application layers, while <code>25-runtime</code> is allowed to move to a patched Hummingbird image. The production section below separates the update tag from the immutable digest we deploy.</p><p>Jib creates separate layers for the Quarkus libraries, <code>quarkus-run.jar</code>, framework-generated files, and application files. A normal code edit usually changes the application layer while the dependency layers stay cached. This is why Jib can remain fast without a handwritten multi-stage Dockerfile.</p><p>Jib targets <code>linux/amd64</code> when no platform is configured. Podman can emulate it on many ARM64 machines, but it prints a platform mismatch warning and startup is slower. Build the native platform explicitly:</p><pre><code><code>./mvnw verify \
  -DskipITs=false \
  -Dquarkus.container-image.build=true \
  -Dquarkus.jib.platforms=linux/arm64</code></code></pre><p>The Hummingbird <code>25-runtime</code> tag publishes both architectures, so either build resolves from the same image name.</p><h2><strong>Run the Service</strong></h2><p>Start the image in one terminal:</p><pre><code><code>podman run --rm \
  --name hardened-quarkus \
  --memory=512m \
  -p 8080:8080 \
  localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT</code></code></pre><p>Call the application from another terminal:</p><pre><code><code>curl -s http://localhost:8080/status | jq</code></code></pre><p>Expected response:</p><pre><code><code>{
  "javaFeatureVersion": 25,
  "service": "hardened-quarkus-jib",
  "status": "ready"
}</code></code></pre><p>Check readiness too:</p><pre><code><code>curl -s http://localhost:8080/q/health/ready | jq</code></code></pre><p>Expected response:</p><pre><code><code>{
  "status": "UP",
  "checks": []
}</code></code></pre><p>The empty <code>checks</code> array is normal for this application. SmallRye Health has no external database or broker to inspect, but the endpoint proves that the management route is present and the process can answer requests.</p><h2><strong>Inspect What Jib Built</strong></h2><p>The HTTP response proves the application works. Image inspection proves the runtime contract.</p><p>Check the user and working directory:</p><pre><code><code>podman image inspect \
  localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT \
  --format 'user={{.Config.User}} workdir={{.Config.WorkingDir}}'</code></code></pre><p>Expected output:</p><pre><code><code>user=65532 workdir=/work</code></code></pre><p>Now inspect the entrypoint:</p><pre><code><code>podman image inspect \
  localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT \
  --format '{{json .Config.Entrypoint}}' | jq</code></code></pre><p>Expected output:</p><pre><code><code>[
  "java",
  "-Djava.util.logging.manager=org.jboss.logmanager.LogManager",
  "-XX:MaxRAMPercentage=70.0",
  "-XX:+ExitOnOutOfMemoryError",
  "-jar",
  "quarkus-run.jar"
]</code></code></pre><p>Before the next command, predict the result. The entrypoint uses <code>java</code> directly, and we chose the distroless runtime variant. There should be no <code>/bin/sh</code> available to run <code>id</code>:</p><pre><code><code>podman run --rm \
  --entrypoint=/bin/sh \
  localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT \
  -c 'id'</code></code></pre><p>The command must exit with a non-zero status and report that <code>/bin/sh</code> cannot be found. If it prints a user ID, you built from a different image variant or replaced the entrypoint with something that pulled a shell into the final filesystem.</p><h2><strong>Push Directly to a Registry</strong></h2><p>The local build needs Podman because Jib must load the image somewhere and the integration test must run it. A separate CI packaging job can push straight to a registry:</p><pre><code><code>./mvnw package \
  -Dquarkus.container-image.push=true \
  -Dquarkus.container-image.image=quay.io/acme/hardened-quarkus-jib:1.0.0</code></code></pre><p>Provide registry credentials through the CI secret store with <code>QUARKUS_CONTAINER_IMAGE_USERNAME</code> and <code>QUARKUS_CONTAINER_IMAGE_PASSWORD</code>. Jib talks to the registry API, so this packaging path needs neither a Dockerfile nor a privileged Docker-in-Docker service. It runs <code>@QuarkusTest</code>, but it does not launch the pushed image. Keep the packaged-container test in a job that has a container runtime, or run it after deployment against the promoted image.</p><p>For a multi-architecture release, push both supported platforms:</p><pre><code><code>./mvnw package \
  -Dquarkus.container-image.push=true \
  -Dquarkus.container-image.image=quay.io/acme/hardened-quarkus-jib:1.0.0 \
  -Dquarkus.jib.platforms=linux/amd64,linux/arm64</code></code></pre><p>A local image store can only run one platform at a time. The registry is the right destination for the multi-architecture index.</p><p>Verify the published index:</p><pre><code><code>podman manifest inspect \
  quay.io/acme/hardened-quarkus-jib:1.0.0 \
  | jq '.manifests[].platform'</code></code></pre><p>Expected platforms:</p><pre><code><code>{
  "architecture": "amd64",
  "os": "linux"
}
{
  "architecture": "arm64",
  "os": "linux"
}</code></code></pre><h2><strong>Make the Image Survive Production</strong></h2><p>The hardened base removes a large amount of operating-system content. It does not remove our responsibility for updates, provenance, application dependencies, or runtime diagnosis.</p><h3><strong>Rebuild tags and deploy digests</strong></h3><p><code>25-runtime</code> tracks the Java 25 line and receives patched image revisions. Rebuilding against that tag is convenient because Jib picks up the new base layers. A released workload should deploy an immutable digest so a rollback always returns to the same bytes.</p><p>Use both behaviors in the pipeline: resolve and verify the current approved Hummingbird tag, build the application, push the result, and deploy the final application image by digest. Schedule rebuilds even when the application source has not changed. Hardened images are immutable, so a base-image fix reaches production only after another application image is built and deployed.</p><h3><strong>Verify the base signature</strong></h3><p>The Hummingbird documentation provides Red Hat&#8217;s public signing key. Verify the base before the build:</p><pre><code><code>cosign verify \
  --key https://security.access.redhat.com/data/63405576.txt \
  --insecure-ignore-tlog \
  registry.access.redhat.com/hi/openjdk:25-runtime</code></code></pre><p>This checks the signature against the supplied key. <code>--insecure-ignore-tlog</code> skips transparency-log verification, so record the command as signature-only verification without a Rekor inclusion check.</p><h3><strong>Debug through the process boundary</strong></h3><p>A missing shell changes the debugging routine. Use <code>podman logs hardened-quarkus</code>, HTTP health endpoints, metrics, traces, heap dumps written to an approved volume, and an external debug container. Installing a shell into the production image restores the tools the hardened base deliberately removed.</p><p>The same rule applies to startup scripts. An entrypoint such as <code>/bin/sh run-java.sh</code> works with many general-purpose base images and fails immediately here. The direct Java entrypoint we inspected earlier is part of the compatibility proof.</p><h3><strong>Keep trust stores and SBOMs separate</strong></h3><p>Enterprise services often call internal TLS endpoints. The Hummingbird Java image reads its truststore from <code>/etc/pki/ca-trust/extracted/java/cacerts</code>. Mount a prepared truststore read-only, or create a derived image through the documented builder-to-runtime flow. On SELinux systems, remember the <code>:Z</code> or <code>:z</code> relabel option for Podman volumes.</p><p>The base-image SBOM describes the Hummingbird content. It does not include the Quarkus libraries we add in later layers. Scan the final application image and generate an application SBOM as a separate release artifact. A quiet base scan can still sit underneath a vulnerable Maven dependency. Containers have a sense of humor about organizational boundaries.</p><p>The FIPS variants have another hard boundary: the <a href="https://hummingbird-project.io/docs/using/overview/#fips-variants-latest-fips-latest-fips-builder">Hummingbird documentation</a> states that validation applies when they run on RHEL systems installed in FIPS mode. Changing an image tag cannot make an arbitrary host compliant.</p><h2><strong>Conclusion</strong></h2><p>We built a Quarkus JVM image with a direct Java entrypoint, a non-root Hummingbird runtime, stable application layers, and tests that cross the real container boundary. Jib removes the Dockerfile maintenance, while the inspection and failure checks keep the runtime assumptions explicit.</p><p>The complete code is available in the <a href="https://github.com/myfear/the-main-thread/tree/main/hardened-quarkus-jib">hardened Quarkus Jib example</a>.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Agentic Engineering Still Needs Human Judgment]]></title><description><![CDATA[After 510 daily posts on The Main Thread, this is a practical note on publishing cadence, Quarkus craft, and why agent-assisted work still needs verification.]]></description><link>https://www.the-main-thread.com/p/510-posts-later</link><guid isPermaLink="false">https://www.the-main-thread.com/p/510-posts-later</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Sat, 11 Jul 2026 06:09:09 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/6f1adec7-b708-442c-8e1c-a766f8d8d904_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>510 is a strange number to write about.</p><p>It is not a round anniversary. It does not look good on a sticker. It has no obvious meaning unless you already know the counter.</p><p>For me, it means 510 daily posts on The Main Thread.</p><p>That is a lot of words. It is also a lot of small Quarkus applications, build failures, agent experiments, screenshots, wrong assumptions, late evenings, and mornings where I opened the editor because the streak was still alive.</p><p>When I started this, the motivation was simple: </p><ul><li><p>Do I still know Quarkus well enough to explain it from the inside?</p></li><li><p>Can I keep up with what is happening in Java, cloud-native development, standards, and AI-assisted engineering?</p></li><li><p>Can I use agents better by using them every day, on real writing and real code, with real constraints?</p></li></ul><p>The Main Thread was never meant to be a place for polished summaries from a distance. I wanted to build something, test it, break it, fix it, and then explain what survived. A working notebook, basically. For people who still care about what the system does after the diagram looks nice.</p><p>Publishing every day helped with that.</p><p>It kept me close to the material. If Quarkus changed, I had to notice. If an extension behaved differently than expected, I had to find out why. If an agent produced code that looked fine but failed under a real test, I had to deal with the failure before publishing.</p><p>That discipline helped and it also had a cost.</p><p>Daily publishing changes how you look at your day. Every topic becomes a possible post. Every experiment becomes a thing you owe yourself to finish. Every unfinished idea waits in the corner (an almost endless Note on my phone in fact) and quietly adds pressure.</p><p>From the outside, this looks productive. For a while, it is. From the inside, it can also become heavy. You start measuring time in publishable units, which is a strange way to live and a worse way to think.</p><p>The last 510 posts also taught me a lot about agents.</p><p>Agents help. They really do.</p><p>They help with structure. They help with first drafts. They help with small code changes, test scaffolding, option comparison, and finding cases I missed. They are useful sparring partners when I already know what I am trying to build.</p><p>But agents do not magically create high-value technical content.</p><p>They can produce a lot of text. That does not mean the text contains insight.</p><p>They can generate code. That does not mean they understand the failure mode.</p><p>They can summarize documentation. That does not mean they know why a senior engineer should care.</p><p>The valuable part still needs judgment. It needs technical memory. It needs taste. It needs the ability to say: this example is too clean, this abstraction hides the real problem, this tutorial needs a failure case, this claim needs verification, this topic is not ready yet. And sometimes this judgment needs confirmation from engineering or the upstream maintainers even. That takes time.</p><p>Sometimes the agent makes that work faster. Sometimes it creates more work because the draft sounds confident before it is correct. For daily publishing, that difference matters. Speed helps only when verification keeps up.</p><p>After 510 daily posts, I know two things better than before.</p><p>First, I still enjoy the craft. I still enjoy Quarkus. I still enjoy Java. I still enjoy the plain parts that make systems reliable: standards, tests, contracts, observability, failure handling, and small design choices that keep code understandable. And I enjoy working with the new agentic overlords. It&#8217;s fun to &#8220;write&#8221; software again. And an even bigger joy to figure out how to eliminate all that vibecoded mess and turn it into something that is architecturally sound.</p><p>Second, daily publishing has done its job.</p><p>So I am changing the cadence.</p><p>This is not the end of The Main Thread. I will keep writing about Quarkus, Java, standards, agentic engineering, and the engineering practices we need around generated code. I will keep testing ideas in code. I will keep looking at where agents help, where they fail, and where they need tighter boundaries.</p><p>But I want to give the next posts more room.</p><p>Some examples need a second test. Some topics need a few days before they are worth publishing. Some claims need more proof. Some articles should sit overnight so the confident parts can become the correct parts. And some topics do even need multiple parts.</p><p>The daily streak was good at making me publish. It was less good at making me wait and think.</p><p>510 posts is not a clean milestone. It is enough evidence for me though.</p><p>Consistency works. Agents help when there is engineering judgment around them. And a streak is only worth keeping while it serves the work.</p><p>I am keeping the work.</p><p>I am changing the rhythm.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Give IBM Bob a Memory of the Quarkus Codebase]]></title><description><![CDATA[Indexed Quarkus with codebase-memory-mcp, ran the same @ConfigMapping trace with grep vs graph tools, compared cost]]></description><link>https://www.the-main-thread.com/p/quarkus-codebase-memory-mcp</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-codebase-memory-mcp</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Fri, 10 Jul 2026 06:08:42 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/1c421d21-a244-4329-98cf-4c2917869eb4_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Quarkus is a hard repo for a coding agent to search with plain tools. This might be the only thing it has in comon with some pretty large legacy projects out there. As I can not really share examples with customer projects, I decided to use that as an opportunity rather than a limitation.</p><p>Quarkus source tree splits into <code>core/</code>, <code>extensions/</code>, and <code>independent-projects/</code>. The same class names show up in deployment and runtime modules. Generated config classes, synthetic beans, and build-item scheduling sit between the code you can read and the behavior you care about. If you just use <code>rg</code> it might work on the first jump. By the third or fourth jump you are reading whole files again and hoping you opened the right module. And the same is true for coding agents. </p><p><a href="https://github.com/DeusData/codebase-memory-mcp">codebase-memory-mcp</a> builds a local knowledge graph from a checkout and exposes it through MCP. I wanted to see whether that graph helps <a href="https://www.ibm.com/products/ai-coding-agent">IBM Bob</a> on a real Quarkus platform tree and not a toy service with twelve files and one obvious call chain.</p><p>I indexed Quarkus, wired the MCP server into Bob, and ran the same <code>@ConfigMapping</code> trace twice: once with shell tools only, once with graph tools. The interesting part is not whether Bob can answer the question. Both paths got there. The interesting part is what each path costs, what it misses, and what that says about agent tooling on framework-scale codebases.</p><h2><strong>The setup</strong></h2><p><a href="https://github.com/DeusData/codebase-memory-mcp">codebase-memory-mcp</a> ships as a single static binary. No Docker, no API keys, no built-in LLM. Bob still does the thinking. The MCP server answers structural questions about the indexed graph.</p><pre><code><code>curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash</code></code></pre><p>The installer puts the binary at <code>~/.local/bin/codebase-memory-mcp</code>. If your shell cannot see it:</p><pre><code><code>echo 'export PATH="$HOME/.local/bin:$PATH"' &gt;&gt; ~/.zshrc
source ~/.zshrc</code></code></pre><p>On macOS, Gatekeeper sometimes blocks a fresh download:</p><pre><code><code>xattr -cr ~/.local/bin/codebase-memory-mcp
codesign --force --sign - ~/.local/bin/codebase-memory-mcp</code></code></pre><p>The install script does cover all of this. Just in case you want to do this manually.</p><p>Bob reads MCP config from <code>.bob/mcp.json</code> in the workspace root. Open your <strong>Quarkus checkout</strong> as the Bob workspace and create the file there:</p><pre><code><code>{
  "mcpServers": {
    "codebase-memory-mcp": {
      "command": "codebase-memory-mcp",
      "args": [],
      "groups": ["read", "mcp"],
      "disabled": false
    }
  }
}</code></code></pre><p>If Bob says the command is missing, set <code>"command"</code> to the full path under <code>~/.local/bin</code>. The <code>groups</code> entry is what mattered in my runs: Bob could call graph tools from the Quarkus workspace without extra wiring.</p><p>Reload MCP. You should see tools such as <code>index_repository</code>, <code>search_graph</code>, <code>query_graph</code>, <code>trace_path</code>, <code>get_code_snippet</code>, and <code>get_architecture</code>.</p><h3><strong>Indexing</strong></h3><p>I indexed from the shell first:</p><pre><code><code>codebase-memory-mcp index ~/Projects/quarkus</code></code></pre><p>On my laptop, with <code>v0.8.1</code> and cache under <code>/tmp</code>, the full Quarkus index reported 29,206 files, 375,498 nodes, 1,587,540 edges, and about 24 seconds of wall time. Peak RSS hit roughly 12 GB during the heavy passes. That is not instant, but it is fast enough to treat as a repeatable step before a debugging session.</p><p>Graph data lives under <code>~/.cache/codebase-memory-mcp/</code> by default. Bob names the project from the checkout path. Run <code>list_projects</code> once and use whatever name Bob shows you in the prompts below.</p><p>You can also ask Bob to index through MCP. Paste this into a fresh chat:</p><pre><code><code>The Quarkus source checkout is open in this workspace.

Index it with codebase-memory-mcp if it is not indexed already.

Rules for this run:

- Call list_projects first.
- If the Quarkus project is missing, call index_repository with the workspace path.
- If the project already exists, say so and do not re-index.
- Do not start the @ConfigMapping trace yet.
- Do not spawn subagents.

Report back with:

1. Whether the repo was already indexed or needed a fresh index
2. File, node, and edge counts if the tool returns them
3. Any error or slow step you hit</code></code></pre><p>Bob called <code>list_projects</code>, found the existing index at 356k nodes and 1.5M edges, and stopped. Cost about nine cents. <code>list_projects</code> itself took over a minute on cold start. That is worth remembering if you are waiting on Bob and wondering whether the index failed. Indexing cost happen.</p><h2><strong>The question</strong></h2><p>The benchmark task:</p><blockquote><p><strong>Trace how a </strong><code>@ConfigMapping</code><strong> class moves from deployment scanning and generated mapping classes to synthetic bean registration and runtime creation.</strong></p></blockquote><p>&#8220;Weaker&#8221; versions of this question send you in the wrong direction. &#8220;Who calls <code>discoverConfigMappings</code>?&#8221; sounds precise. Quarkus does not schedule build steps through normal Java call chains. It schedules them through build-item producers and consumers, which are objects passed between <code>@BuildStep</code> methods. A <strong>build item</strong> is not a method call. A <strong>synthetic bean</strong> is a CDI bean registered at build time. <code>@ConfigMapping</code> injection often ends there before runtime code sees a constructor you can grep.</p><p>Better questions:</p><ul><li><p>Which build steps produce and consume <code>ConfigMappingBuildItem</code>?</p></li><li><p>Which deployment code registers the synthetic bean for a mapping?</p></li><li><p>Which runtime creator calls <code>SmallRyeConfig#getConfigMapping(...)</code>?</p></li></ul><p>The path crosses <code>core/deployment</code>, <code>extensions/arc/deployment</code>, <code>extensions/arc/runtime</code>, and <code>core/runtime</code>. That is the point. A tool that only works on single-module apps would not tell us much.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!eq5a!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!eq5a!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 424w, https://substackcdn.com/image/fetch/$s_!eq5a!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 848w, https://substackcdn.com/image/fetch/$s_!eq5a!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 1272w, https://substackcdn.com/image/fetch/$s_!eq5a!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!eq5a!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png" width="378" height="712.125" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:2743,&quot;width&quot;:1456,&quot;resizeWidth&quot;:378,&quot;bytes&quot;:516363,&quot;alt&quot;:&quot;Quarkus Graph Mermaid&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/204408662?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Quarkus Graph Mermaid" title="Quarkus Graph Mermaid" srcset="https://substackcdn.com/image/fetch/$s_!eq5a!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 424w, https://substackcdn.com/image/fetch/$s_!eq5a!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 848w, https://substackcdn.com/image/fetch/$s_!eq5a!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 1272w, https://substackcdn.com/image/fetch/$s_!eq5a!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6147b02d-3975-49ac-90b3-6638c217e61b_2458x4630.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Build-time work sits on the left. Runtime materialization sits on the right. The line you have to draw in your head is the build-item boundary: <code>ConfigMappingBuildItem</code> is the handoff from core deployment to arc deployment. <code>trace_path</code> will not draw that line for you.</p><h2><strong>A map before the trace</strong></h2><p>Before the <code>@ConfigMapping</code> question, I asked Bob for a repo map. Fresh chat, graph indexed, MCP loaded:</p><pre><code><code>The Quarkus source tree is indexed in codebase-memory-mcp.

Project name: Users-meisele-Projects-quarkus

Give me a first-pass onboarding map of the repo.

Rules for this run:

- Call get_architecture first with the project name above.
- Do not spawn subagents.
- Do not open source files.
- Do not create HTML artifacts.
- Summarize dominant languages, the largest package areas, and rough boundaries between major subsystems.

Report back with:

1. The tool output you used (name only &#8212; do not paste the full JSON)
2. The five largest package areas you would tell a new contributor about
3. One subsystem you would inspect first if the task were extension work in arc or resteasy-reactive
</code></code></pre><p><code>get_architecture</code> returned in 1.4 seconds and named <code>resteasy-reactive</code>, <code>arc</code>, <code>bootstrap</code>, <code>qute</code>, <code>vertx-http</code>, and <code>tools</code> as dominant package areas. Bob did not open a single source file. Cost about 0.2 Bobcoins.</p><p>On the first pass I had left subagent instructions in the prompt. Bob spawned explore subagents, they could not see MCP in that run, and Bob recovered from <code>get_architecture</code> alone. Subagents can use MCP if you configure them. For graph-first work I still keep them out of the prompt. The default explore subagent reaches for grep and adds noise.</p><p><strong>Conclusion:</strong> <code>get_architecture</code> is worth the index cost on its own. For a platform repo, five minutes of orientation without reading files is a real win. It does not replace code reading. It tells you which neighborhood to start in.</p><h2><strong>Baseline: grep and file reads</strong></h2><p>Fresh Bob chat. MCP disabled for the task. Paste:</p><pre><code><code>Trace how @ConfigMapping classes move from deployment scanning and generated mapping classes to synthetic bean registration and runtime creation.

Rules for this run:

- Use only shell commands and file reads (execute_command, grep, read_file).
- Do not use codebase-memory-mcp or any MCP graph tools.
- Do not load skills, spawn subagents, or create HTML artifacts.
- Start with rg ConfigMappingBuildItem and open files from the hits.
- Stop when you can name the main deployment steps, the arc deployment step, and the runtime creator.

Report back with:

1. Files you opened, in order (count them)
2. Which steps are build-time and which are runtime
3. What you are still unsure about</code></code></pre><p>The first useful <code>rg</code> hits look promising:</p><pre><code><code>core/deployment/.../ConfigGenerationBuildStep.java:196:    void discoverConfigMappings(
core/deployment/.../ConfigGenerationBuildStep.java:225:    void generateConfigMappings(
extensions/arc/deployment/.../ConfigMappingProcessor.java:86:    void registerConfigMappingBeans(
extensions/arc/runtime/.../ConfigMappingCreator.java:13:public class ConfigMappingCreator implements BeanCreator&lt;Object&gt; {</code></code></pre><p>Bob still opened eight files. It loaded the Quarkus <code>writing-build-steps</code> skill despite the prompt. The final answer traced <code>ExtensionLoader</code> through <code>ConfigGenerationBuildStep</code>, <code>ConfigMappingProcessor</code>, and <code>ConfigMappingCreator</code> with correct build-item language. It went deeper on bootstrap than the graph runs did. Cost about eighty-seven cents.</p><p><strong>Conclusion:</strong> Plain tools can answer hard Quarkus questions if the agent already knows the platform model. The cost is context. Every file read pulls deployment code, static imports, and generated-class references into the chat. Bob got the answer. It paid for that answer in tokens and in files it had to open before it knew which methods mattered.</p><h3><strong>The </strong><code>writing-build-steps</code><strong> skill</strong></h3><p>One reason the baseline answer sounded like Quarkus and not generic Java: Bob loaded the <code>writing-build-steps</code> skill before its first <code>rg</code> pass, even though the prompt said not to load skills.</p><p>That skill does not come from codebase-memory-mcp. It comes from <a href="https://github.com/quarkusio/quarkus-agent-mcp">quarkus-agent-mcp</a>, the Quarkus project&#8217;s MCP server, which loads skills from the <a href="https://github.com/quarkusio/skills">quarkusio/skills</a> repository. When you open the Quarkus checkout in Bob with <code>quarkus-agent-mcp</code> in <code>.mcp.json</code>, which many Quarkus contributors already have, Bob can pull in bundled skills through <code>quarkus_skills</code>. <code>writing-build-steps</code> is one of them. It documents how <code>@BuildStep</code> methods produce and consume build items, how ordering follows dependency edges rather than source order, and when to use recorders in deployment modules.</p><h2><strong>Graph path: the same question, different tools</strong></h2><p>Fresh chat. MCP loaded. Index warm. Paste (replace the project name with yours from <code>list_projects</code>):</p><pre><code><code>The Quarkus source tree is indexed in codebase-memory-mcp.

Project name: Users-meisele-Projects-quarkus

Trace how @ConfigMapping classes move from deployment scanning and generated mapping classes to synthetic bean registration and runtime creation.

Rules for this run:

- Use codebase-memory-mcp MCP tools only for the first 15 tool calls: search_graph, query_graph, get_code_snippet, and trace_path if a plain call chain is the right question.
- Do not spawn subagents.
- Do not create HTML artifacts.
- Do not load skills.
- Use grep or read_file only after the graph narrows the target (maximum one read_file for confirmation).
- Quarkus build steps connect through build items, not normal Java call chains. Say that explicitly in your answer.

Required MCP sequence:

1. list_projects &#8212; confirm project name
2. query_graph with this Cypher:

MATCH (c:Class)-[:DEFINES_METHOD]-&gt;(m:Method)
WHERE c.name IN ['ConfigGenerationBuildStep', 'ConfigMappingProcessor']
RETURN c.name AS class_name, m.name AS method_name, m.file AS file_path
ORDER BY class_name, method_name

3. search_graph for ConfigMappingCreator and ConfigMappingBuildItem
4. get_code_snippet for the methods you need &#8212; not whole files
5. Optional: trace_path on ConfigMappingCreator only if you explain why a call chain applies

Report back in plain markdown (no HTML):

1. MCP tools you called, in order
2. Files and methods the graph surfaced
3. Which steps are build-time and which are runtime
4. Any graph edge you distrust and why (especially in_degree = 0 on build steps)

If get_code_snippet fails with "symbol not found", run search_graph again and use the qualified_name from the result, not a Java package name you guess.</code></code></pre><p>I ran this twice.</p><p><strong>First pass</strong>: loose prompt, no guardrails. Bob made about thirty graph tool calls, skipped <code>query_graph</code>, fell back to grep and one <code>read_file</code>, and generated an HTML artifact nobody asked for. The answer was strong. It explicitly distrusted <code>in_degree = 0</code> on <code>@BuildStep</code> methods. Cost <strong>2.45 Bobcoin</strong> and nearly a million input tokens. Most of that was Bob context, workspace rules, Quarkus <code>AGENTS.md</code>, and an eight-thousand-token HTML detour, not the MCP queries themselves.</p><p><strong>Second pass</strong>: same prompt with the guardrails above. Bob called <code>query_graph</code> first. Cypher returned methods but empty <code>file_path</code> values. Bob recovered through <code>search_graph</code> qualified names, then <code>get_code_snippet</code>. No HTML. No file reads. It stated plainly that <code>trace_path</code> is the wrong tool for build-item wiring. Cost <strong>0.49 Bobcoin</strong>.</p><p>That five-fold cost drop is the first lesson. On framework repos, <strong>prompt shape dominates tool choice</strong>. The graph did not get five times faster. Bob stopped doing expensive things the prompt now forbids.</p><p>The second lesson is about <strong>metadata gaps</strong>. Cypher listed 29 methods on <code>ConfigGenerationBuildStep</code> and 4 on <code>ConfigMappingProcessor</code> in under a second. <code>get_code_snippet</code> failed until Bob passed <code>qualified_name</code> values from <code>search_graph</code>, not guessed package paths. The graph knows structure. It does not always surface file paths in the shape your agent expects. Plan for a two-step lookup: Cypher to enumerate, <code>search_graph</code> to locate.</p><p>The third lesson is about <strong>what the graph cannot see</strong>. I ran a short follow-up with graph tools only:</p><pre><code><code>The Quarkus graph project is Users-meisele-Projects-quarkus.

This is a short graph-only check. Do not open source files. Do not spawn subagents. Do not create HTML artifacts.

Run these MCP tools in order and summarize the results:

1. query_graph:

MATCH (c:Class)-[:DEFINES_METHOD]-&gt;(m:Method)
WHERE c.name IN ['ConfigGenerationBuildStep', 'ConfigMappingProcessor']
RETURN c.name AS class_name, m.name AS method_name, m.file AS file_path
ORDER BY class_name, method_name

2. search_graph with name pattern ConfigMappingBuildItem, label Class, limit 10
3. search_graph with name pattern ConfigMappingCreator, label Class, limit 10
4. search_graph with name pattern discoverConfigMappings, label Method, limit 5
5. search_graph with name pattern generateConfigMappings, label Method, limit 5

Report back with:

1. Methods listed for each deployment class
2. Whether discoverConfigMappings or generateConfigMappings show in_degree = 0
3. One sentence on why that matters for Quarkus build steps vs Java call graphs</code></code></pre><p>Both <code>discoverConfigMappings</code> and <code>generateConfigMappings</code> showed <code>in_degree = 0</code>. Bob explained why that is normal: <code>@BuildStep</code> entry points are invoked by the build framework, not by other Java methods in the graph. Cost 0.16 Bobcoins. No file reads.</p><p>If you treat <code>in_degree = 0</code> as dead code, you will misread Quarkus deployment. If you treat <code>trace_path</code> as proof of build-step ordering, you will misread it too. The graph is a <strong>search accelerator</strong>, not a build engine simulator.</p><h2><strong>What held up</strong></h2><p><strong>Indexing is practical.</strong> A full Quarkus platform tree in under half a minute, on a laptop with enough RAM, changes the economics. You can afford to index before a hard debugging session instead of treating graph search as a batch job.</p><p><strong>Java support is good enough for navigation.</strong> codebase-memory-mcp advertises Hybrid LSP support for Java as of <code>v0.8.0</code>. The old &#8220;Java is syntax-only&#8221; claim is stale. On this task the graph found the right classes and methods across modules quickly. The gaps are semantic, not syntactic.</p><p><strong>Both paths reached the right answer.</strong> Baseline and graph runs named <code>ConfigGenerationBuildStep</code>, <code>ConfigMappingProcessor</code>, and <code>ConfigMappingCreator</code>. They separated build-time discovery and generation from synthetic bean registration and runtime materialization. Neither path invented a fake <code>CALLS</code> chain through <code>ConfigMappingBuildItem</code>.</p><p><strong>Graph path used fewer files.</strong> Baseline: eight full file reads. Graph v2: snippets only. On a platform repo where deployment files run to hundreds of lines, that difference compounds across a session.</p><h2><strong>What still breaks</strong></h2><p><strong>Build items are invisible to call-graph tools.</strong> Producer/consumer relationships between <code>@BuildStep</code> methods do not show up as <code>CALLS</code> edges. You still need Quarkus build-phase knowledge to interpret what the graph returns.</p><p><strong>JVM edges have holes.</strong> Static imports, generated classes, recorders, and synthetic bean paths are hard cases. Some deployment helpers show <code>in_degree = 0</code> even when the source clearly calls them. Do not trust a graph edge on Quarkus deployment code without opening the file once.</p><p><strong>Agents ignore prompt guardrails.</strong> Bob loaded <code>writing-build-steps</code> on the baseline run and generated HTML on graph v1 despite explicit instructions not to. Tighter prompts helped. They did not guarantee obedience. Factor that into cost estimates.</p><p><strong>Agent context is the real bill.</strong> The graph MCP calls were cheap. Bob&#8217;s workspace rules and Quarkus documentation loaded into every chat were not. The expensive run was not slow Cypher. It was a million tokens of context plus an unsolicited HTML report.</p><h2><strong>How I would use this in practice</strong></h2><p>For a platform or large monorepo checkout:</p><ol><li><p>Index once. Keep the cache warm.</p></li><li><p>Run <code>get_architecture</code> before you ask a trace question. Know which subsystem you are in.</p></li><li><p>Use Cypher to enumerate methods on known anchor classes. Use <code>search_graph</code> when you need qualified names or file paths.</p></li><li><p>Fetch snippets, not files, until you need surrounding context.</p></li><li><p>Ask follow-ups that force the agent to classify edges: normal Java call, build-item handoff, synthetic bean path, or &#8220;I would not trust this without reading the source.&#8221;</p></li></ol><p>Score your own runs on four questions:</p><ol><li><p>Did it find the right files?</p></li><li><p>Did it separate build-time work from runtime work?</p></li><li><p>Did it claim a plain call chain where Quarkus uses build items?</p></li><li><p>How much full-file reading was still needed after the first structured result?</p></li></ol><p>On my runs, baseline won on bootstrap depth. Graph v2 won on file count, cost, and speed to the anchor methods. Neither replaced knowing how Quarkus build phases work.</p><h2><strong>Quarkus isn&#8217;t really hard for users</strong></h2><p>But Quarkus is hard to search because the real logic spreads across build-time phases, runtime modules, generated config classes, and synthetic beans. codebase-memory-mcp shrinks that search space on a full platform tree. <code>get_architecture</code> gives an agent a usable map in seconds. Cypher and <code>search_graph</code> get you to the right methods across modules without opening every deployment file along the way.</p><p>The graph does not understand build-item scheduling. It will not tell you which step consumes <code>ConfigMappingBuildItem</code>. It will tell you where to look, faster than grep alone, if you ask the right questions and read the answers with platform knowledge in the loop.</p><p><a href="https://github.com/myfear/the-main-thread/tree/main/quarkus-codebase-memory-mcp">The companion repo with scripts, and the scoring rubric can be found on my main-thread Github repository</a>.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Coding Agent Evaluation Is Not a Benchmark Slide]]></title><description><![CDATA[Measure whether the whole coding-agent workflow produces smaller wrong turns, better recoveries, clear test evidence, and work a human can trust.]]></description><link>https://www.the-main-thread.com/p/coding-agent-evaluation-not-benchmark-slide</link><guid isPermaLink="false">https://www.the-main-thread.com/p/coding-agent-evaluation-not-benchmark-slide</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Thu, 09 Jul 2026 06:08:32 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/1e5cd55d-fb27-491c-8716-a0a2995cd4d4_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A benchmark slide answers one narrow question: did this model score better on this public task set?</p><p>For coding agents, start there. Then evaluate the workflow.</p><p>The work your team cares about starts after the model begins acting. It opens files. It reads repo rules. It loads skills, or ignores them. It calls tools. It edits code. It runs tests. It explains what it changed. A human then has to decide whether the result is safe to merge.</p><p>The eval has to cover that whole loop.</p><p>Model scores still matter. Token charts still matter. A polished demo can still show what is possible. But none of that tells you whether the workflow keeps wrong turns small, recovers from bad tool output, lowers review cost, or gives reviewers results they can trust.</p><p>I want evals to answer that.</p><h2><strong>Benchmarks Measure A Smaller Unit</strong></h2><p>Model benchmarks are good for comparing base capability. They tell you whether one model is generally stronger than another under a fixed test setup. For coding work, they can show signal around bug fixing, repository reasoning, code generation, and test repair.</p><p>I would still look at them. Choosing a weak model and hoping process will save you just creates more work later.</p><p>But a coding-agent workflow has more moving parts than the model. The agent gets instructions. It reads local files. It may have skills for a framework or codebase. It may call search, shell, test, browser, GitHub, database, or deployment tools. It may have permission gates. It may have memory from earlier turns. It may be forced to recover after a bad assumption.</p><p>Most of that never reaches the benchmark.</p><p>One-off demos have a different problem. They feel close to real work because you can watch the agent do things. It reads files, makes a patch, runs a command, and gives a tidy summary.</p><p>One good demo proves that one path worked once. It leaves out how often the agent reads the wrong file, trusts stale docs, deletes a test, changes more than asked, or burns 10 turns recovering from a mistake it made in turn two.</p><p>The expensive part of agent work is often recovery and review. Evaluate that directly.</p><h2><strong>Workflow Evaluation</strong></h2><p>Workflow evaluation means the run is the unit under test.</p><p>A run includes the task prompt, model, skills, tool calls, files read, files changed, commands run, failures, recoveries, final answer, and human review. The model response is only one part of that run.</p><p>For coding agents, ask questions like these:</p><ul><li><p>Did the agent read the right files before editing?</p></li><li><p>Did it keep the change inside the request?</p></li><li><p>Did it notice missing tests?</p></li><li><p>Did it run checks that prove the changed behavior?</p></li><li><p>Did it handle stale docs or repeat them?</p></li><li><p>Did it recover from a failing command by reading the error?</p></li><li><p>Did it stop before unsafe changes?</p></li><li><p>Did the final summary give the reviewer real evidence?</p></li></ul><p>The final answer can sound good while the diff is wrong. We have all seen that version of confidence. It happens often enough to measure.</p><p>So the eval has to inspect the work around the answer: diff, logs, tool trace, tests, touched files, approval points, and review notes.</p><h2><strong>Use Realistic Task Suites</strong></h2><p>Start with small sample projects. A sample project can be a tiny repo, a copied slice of a real service, or a made-up project that keeps one failure mode without exposing private code.</p><p>The sample should be small enough for a human to inspect. It should still contain the mess that makes agent work costly: stale files, partial tests, unclear requests, dangerous shortcuts, and tools that sometimes fail or mislead.</p><p>I would include these paths.</p><p><strong>Easy path</strong><br>The task is clear, local, and covered by tests. The agent should make a small diff and run the obvious checks. This is your baseline.</p><p><strong>Unclear request path</strong><br>The request has two possible meanings. The agent should ask, state its assumption, or choose the lower-risk path and say so. Silent guessing should cost points.</p><p><strong>Failure path</strong><br>The first test run fails. Watch what happens next. A good agent reads the failure, maps it to the right file, and fixes the cause. A weak workflow starts guessing.</p><p><strong>Stale docs path</strong><br>The README points to an old API or framework pattern. The code says something else. The agent should prefer current local evidence or verified docs.</p><p><strong>Missing tests path</strong><br>The code change is easy, but no test covers the behavior. Existing tests passing should not be enough. The agent should add focused coverage or explain why the sample cannot support it.</p><p><strong>Unsafe changes path</strong><br>The tempting fix deletes a test, changes a public contract, rewrites build files, gives the code more access, or runs a risky command. The right behavior may be to stop and ask.</p><p><strong>Bad tool output path</strong><br>A search misses a file. A test runner returns incomplete output. A tool reports success while the output file is missing. The agent should check again before it trusts the tool.</p><p>These tasks can be small. They need to keep the same failure.</p><h2><strong>Measure the Review Cost</strong></h2><p>Pass/fail is not enough.</p><p>Two runs can both pass tests. One changes two files, adds one focused test, and gives the reviewer the exact command output. The other changes 18 files, removes a failing check, and writes a clean summary. Treating both as a pass destroys the signal.</p><p>Track the parts a reviewer pays for.</p><p><strong>Recovery turns</strong><br>How many turns or tool cycles did the agent need after the first wrong move? This shows whether the workflow can repair itself cheaply.</p><p><strong>Review time</strong><br>How long did a human need to approve or reject the result? This is rough, but it is honest. If the patch passes tests and still takes an hour to trust, the workflow is expensive.</p><p><strong>Diff size</strong><br>How much changed? A small diff can be wrong. A large diff can be necessary. But unnecessary churn is review tax.</p><p><strong>Files touched</strong><br>Which files changed? Edits outside the expected area should show up immediately.</p><p><strong>Test evidence</strong><br>Which checks ran? What were the exit codes? Do the checks cover the changed behavior? &#8220;Tests passed&#8221; without this detail is almost useless.</p><p><strong>Rule and policy breaks</strong><br>Did the agent delete tests, change public APIs without calling it out, ignore an approval gate, edit generated files, or use a tool that was not allowed?</p><p><strong>Result quality</strong><br>Is the final patch, doc, migration note, or design write-up something a human can use? Automated checks can catch many problems. They cannot fully judge whether a design belongs in your system.</p><p><strong>Human approval points</strong><br>Did the agent stop when the task crossed into business risk, architecture risk, or hard-to-undo work? Good escalation is a feature.</p><p>A single score hides too much. These measurements show whether a model is faster but messier, a skill reduces recovery turns, or a prompt change improves test evidence while increasing diff size.</p><p>Now the team has something to fix.</p><h2><strong>A Specific Skill Eval</strong></h2><p>Let&#8217;s make this concrete with one skill. I have added the <a href="https://github.com/myfear/the-main-thread/tree/main/review-standards-skill">folder to my Github repository if you want to copy it and play along</a>. </p><p>The example skill is <code>review-standards</code>. Its job is to guide an agent that reviews authentication code. The sample project contains one small file, <code>src/auth.ts</code>, with two problems:</p><ul><li><p>Passwords are hashed with SHA-1</p></li><li><p>Reset tokens are compared with <code>===</code></p></li></ul><p>The skill has three states:</p><ul><li><p>No skill loaded</p></li><li><p>A stale skill that finds auth problems but ignores what the user asked for</p></li><li><p>An improved skill that finds both issues and stays inside the request</p></li></ul><p>The runnable sample lives in <code>review-standards-skill</code>:</p><pre><code><code>review-standards-skill/
  promptfooconfig.yaml
  scripts/mock-review-agent.js
  samples/
    no-skill/
      src/auth.ts
    v1/
      .agents/skills/review-standards/SKILL.md
      src/auth.ts
    v2/
      .agents/skills/review-standards/SKILL.md
      src/auth.ts</code></code></pre><p>The source file is the same in all three sample projects. Only the skill changes.</p><p>The first version of the skill knows about password hashing and token comparison. It does not know that the user may ask for a smaller review. The improved version adds this rule:</p><pre><code><code>Respect what the user asked for. If the user says to review only password
handling, report password issues and leave token findings out of the result.</code></code></pre><p>That rule sounds small. The eval makes it visible.</p><h2><strong>The Promptfoo Config</strong></h2><p><code>promptfoo/promptfoo</code> gives us a repeatable way to run the same tasks against each skill state. The <a href="https://www.promptfoo.dev/docs/guides/test-agent-skills/">Promptfoo skill guide</a> uses the same basic pattern: keep the task and runtime steady, swap the skill version, and compare behavior.</p><p>For this article, I use a local <code>exec:</code> provider. It reads the sample project, returns JSON, and keeps the dashboard runnable without an API key. In a real coding-agent eval, replace the <code>exec:</code> providers with <code>openai:codex-sdk</code>, <code>anthropic:claude-agent-sdk</code>, or another agent provider. Promptfoo&#8217;s provider docs list custom script providers and agent providers, and the skill guide shows the Codex <code>.agents/skills/...</code> layout.</p><p>The config compares three providers:</p><pre><code><code>providers:
  - id: "exec: node scripts/mock-review-agent.js"
    label: no-skill-baseline
    config:
      sampleDir: samples/no-skill

  - id: "exec: node scripts/mock-review-agent.js"
    label: stale-review-standards
    config:
      sampleDir: samples/v1

  - id: "exec: node scripts/mock-review-agent.js"
    label: improved-review-standards
    config:
      sampleDir: samples/v2</code></code></pre><p>Then the tests ask two questions:</p><pre><code><code>tests:
  - description: Finds both auth review issues
    vars:
      request: Review src/auth.ts for password handling and token comparison issues.
      expectedIssues:
        - weak-password-hash
        - timing-unsafe-compare
      outOfRequestIssues: []

  - description: Stays inside password-only review request
    vars:
      request: Review src/auth.ts only for password handling issues.
      expectedIssues:
        - weak-password-hash
      outOfRequestIssues:
        - timing-unsafe-compare</code></code></pre><p>The first test asks whether the run found both known issues. The second test asks whether the run stayed inside the request. When the user asks only for password handling, the token issue should stay out.</p><p>The Promptfoo check scores four things:</p><ul><li><p>Expected issue IDs were found</p></li><li><p>Issue IDs outside the request were not returned</p></li><li><p>Evidence was included</p></li><li><p>The skill was used</p></li></ul><p>For real agents, Promptfoo also supports skill-specific checks. The Promptfoo docs show <code>skill-used</code> checks for agent providers that expose skill/tool calls directly. You can also use trace-based checks when you need to prove that a Codex run read the matching <code>SKILL.md</code>.</p><h2><strong>Running It</strong></h2><p>Run the sample:</p><pre><code><code>cd review-standards-skill
npx promptfoo@latest eval -c promptfooconfig.yaml -o results.json</code></code></pre><p>This eval is supposed to fail some results. That is the point. The weak cases should stay red. Promptfoo returns a non-zero exit code when results fail, which is what you want in CI.</p><p>My run produced this result:</p><pre><code><code>Results:
  &#10003; 3 passed (50.00%)
  &#10007; 3 failed (50.00%)
  0 errors (0%)
Duration: 0s (concurrency: 4)</code></code></pre><p>The dashboard is the easiest way to read it:</p><pre><code><code>npx promptfoo@latest view --port 15500</code></code></pre><p>Open http://localhost:15500</p><p> and select the latest eval. The grid tells the story without much explanation:</p><ul><li><p><code>no-skill-baseline</code> fails both tasks because it finds no issues and has no skill evidence</p></li><li><p><code>stale-review-standards</code> passes the broad auth review but fails the password-only review because it reports the token issue anyway</p></li><li><p><code>improved-review-standards</code> passes both tasks</p></li></ul><p>This is the result I want from a skill eval. It shows the behavior change: the improved skill still found the real issues, and it stopped reporting issues the user did not ask for.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!D489!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!D489!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 424w, https://substackcdn.com/image/fetch/$s_!D489!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 848w, https://substackcdn.com/image/fetch/$s_!D489!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 1272w, https://substackcdn.com/image/fetch/$s_!D489!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!D489!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png" width="1456" height="897" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:897,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:539593,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/204390485?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!D489!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 424w, https://substackcdn.com/image/fetch/$s_!D489!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 848w, https://substackcdn.com/image/fetch/$s_!D489!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 1272w, https://substackcdn.com/image/fetch/$s_!D489!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff5d1fd82-4d2f-4ff6-b8e0-e8e27546638d_3024x1864.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The same shape works with a real agent. Keep the sample and checks. Swap the provider. Let the dashboard show whether the skill reduced wrong turns, added evidence, stayed inside the request, or just made the answer sound more serious.</p><h2><strong>The Eval Loop</strong></h2><p>The loop is small:</p><pre><code><code>skill version
  -&gt; same sample project
    -&gt; same agent/provider
      -&gt; output JSON + trace + files
        -&gt; promptfoo checks
          -&gt; dashboard review
            -&gt; skill edit
              -&gt; rerun</code></code></pre><p>The dashboard shows why the run failed: it missed expected issues, reported issues outside the request, gave no evidence, or did not use the skill. Now you have something to fix.</p><h2><strong>What Promptfoo Should Not Decide Alone</strong></h2><p>Automated evals are good at catching repeatable failures. They are weaker at judging whether a solution belongs in your architecture.</p><p>Promptfoo should not be the final authority on:</p><ul><li><p>Architecture judgment</p></li><li><p>Business risk</p></li><li><p>Security acceptance for real production data</p></li><li><p>Whether a migration is safe for your customers</p></li><li><p>Whether the design is maintainable in your codebase</p></li><li><p>Final human acceptance</p></li></ul><p>You can catch many bad patterns with checks: large diffs, missing tests, files that should not change, unsafe commands, weak evidence, and broken output shape. You can also use model-graded scoring rules for some quality checks when scale matters.</p><p>But architecture and business risk still need humans. A good eval suite reduces review cost. It does not make the final merge decision.</p><h2><strong>Avoid Chasing the Score</strong></h2><p>Score chasing starts when the number becomes more valuable than the behavior.</p><p>The signs are easy to spot. The suite has only happy paths. The checks look at wording because wording is easy to grade. The samples are tuned until the preferred workflow wins. &#8220;Flaky&#8221; failures disappear before anyone asks what they show. The final report has a clean percentage, and no reviewer can explain what went wrong.</p><p>That gives you confidence without trust.</p><p>Design evals so failures stay visible.</p><p><strong>Keep the suite small enough to understand</strong><br>A dozen good cases beat 300 shallow ones when you are still learning what breaks. Add scale after the signal is clear.</p><p><strong>Include failure paths</strong><br>Broken tests, stale docs, missing files, unsafe changes, and bad tool output should be normal eval inputs.</p><p><strong>Freeze samples and expected behavior</strong><br>Version the sample project, task prompt, skills, and scoring scripts. If the task changes every run, the score tells you very little.</p><p><strong>Store run output</strong><br>Keep the diff, logs, final answer, tool trace when available, and human review notes. Scores without those files are hard to debug.</p><p><strong>Separate hard gates from diagnostics</strong><br>Deleted tests, files that should not change, unsafe commands, and missing required evidence can fail CI. Larger diffs, extra recovery turns, or more files read may be review signals instead.</p><p><strong>Reward correct escalation</strong><br>Sometimes the right output is &#8220;I need approval before this change.&#8221; If your eval marks that as failure because no patch was produced, you are training the workflow to be reckless.</p><p><strong>Watch what the numbers encourage</strong><br>Small-diff scoring can discourage necessary tests. Test-pass scoring can encourage weak tests. Low-token scoring can make the agent stop reading too early. Each number needs another number or review check beside it.</p><p><strong>Check with humans</strong><br>Have reviewers sample passing and failing runs. If humans disagree with the automated score, fix the scoring.</p><p>An eval suite should make failures cheaper to find and easier to explain. If it only gives you a better slide, it has become part of the problem.</p><h2><strong>A Practical Starting Set</strong></h2><p>If I were starting from zero, I would create one small sample repo and five tasks:</p><ol><li><p>A clean bug fix with an existing failing test</p></li><li><p>The same bug fix with no test coverage</p></li><li><p>An unclear request where the agent should ask or state assumptions</p></li><li><p>A stale-docs task where the README points to an old API</p></li><li><p>An unsafe-change task where the tempting fix edits a public contract</p></li></ol><p>For each task, I would collect:</p><ul><li><p>Final diff</p></li><li><p>Files touched</p></li><li><p>Commands run and exit codes</p></li><li><p>Test evidence</p></li><li><p>Number of recovery turns</p></li><li><p>Rule and policy breaks</p></li><li><p>Human review verdict and review time</p></li></ul><p>Then I would run the suite across:</p><ul><li><p>Current workflow</p></li><li><p>Workflow with one prompt change</p></li><li><p>Workflow with one skill change</p></li><li><p>One stronger model</p></li><li><p>One cheaper model</p></li><li><p>A plain model baseline where possible</p></li></ul><p>This is enough to start learning. You are not trying to prove a universal law on day one. You are trying to see whether the workflow fails in small, understandable ways.</p><p>After that, add more samples, more models, and more automation.</p><h2><strong>The Real Score</strong></h2><p>The real score is whether the workflow keeps wrong turns small, recovers with evidence, respects approval boundaries, and leaves a human with work they can review without replaying the whole session in their head.</p><p>Model benchmarks help you choose candidates. Token charts help you watch cost. Demo runs show what is possible. Workflow evaluation tells you whether the whole system is becoming safer and cheaper to use.</p><p>Coding agents do not fail as benchmark rows. They fail as diffs, missed tests, stale assumptions, unsafe tool calls, and tired reviewers.</p><p>A good eval loop makes those failures visible while they are still small enough to fix.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Wanaku MCP Router: Getting Started on Your Laptop]]></title><description><![CDATA[Run a local Wanaku router, import an HTTP toolset, forward an upstream MCP server, and merge both into one agent catalog.]]></description><link>https://www.the-main-thread.com/p/wanaku-getting-started</link><guid isPermaLink="false">https://www.the-main-thread.com/p/wanaku-getting-started</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Wed, 08 Jul 2026 06:08:05 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/51f8c23f-6aa9-4e85-b617-dd78d4734519_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Agent demos are easy to fake. Add one clean endpoint, wire one happy-path tool, and the screenshot looks finished. The hard part starts when the agent needs to talk to systems you already have. One HTTP API here. One file tree there. Maybe an MCP server another team already runs and nobody wants to rewrite just because an LLM showed up.</p><p>That is the problem Wanaku is trying to solve.</p><p>Wanaku is an open-source MCP router built on Quarkus and Apache Camel. Your agent connects to one Wanaku endpoint over the <a href="https://modelcontextprotocol.io/">Model Context Protocol</a>, and Wanaku manages the catalog behind it: tools and forwards to other MCP servers. That gives you one place to manage what the agent can see instead of hand-wiring every capability into every client.</p><p>I wanted to try Wanaku since a while and had this on my to-do list. Finally got a chance to run an example and share what I learned. From idea to this tutorial, a few parts did not survive contact with the real CLI. The working path below uses a local Wanaku router, one imported HTTP toolset, and one forwarded upstream MCP server. </p><h2><strong>What We Are Building</strong></h2><ul><li><p>A local Wanaku router on http://localhost:8080</p></li><li><p>One imported HTTP toolset from the official <a href="https://github.com/wanaku-ai/wanaku-toolsets">wanaku-toolsets repository</a></p></li><li><p>One forwarded upstream MCP server: the official <a href="https://github.com/modelcontextprotocol/servers/tree/main/src/everything">Everything reference server from the MCP servers repository</a></p></li><li><p>A verified catalog that shows local HTTP tools and remote MCP tools behind the same router</p></li></ul><p>This is not the exact bridge I wanted to use. I started with the official filesystem server because that looks closer to real work. In Wanaku 0.1.3, that path failed for reasons I explain later. The bridge below is the one that worked end to end.</p><h2><strong>What You Need</strong></h2><ul><li><p>Java 21 or later</p></li><li><p>A recent Node.js with <code>npx</code></p></li><li><p>Internet access on first run, because Wanaku and the MCP server packages need to be downloaded (obvl)</p></li><li><p>&#9749;&#65039;&#9749;&#65039;&#9749;&#65039;</p></li></ul><p>I recommend Java 21 for this article. In my validation run, Wanaku 0.1.3 installed on Java 25, but the CLI needed extra JVM flags because of module access rules. </p><p>Check the local runtime first:</p><pre><code><code>java -version
node -v
npx --version</code></code></pre><p>If you are already on Java 25 and want to stay there, I had to use this during validation:</p><pre><code><code>export JAVA_TOOL_OPTIONS='--add-opens=java.base/java.lang=ALL-UNNAMED'</code></code></pre><p>That is a workaround. I could not find official docs about Java version support with Wanaku yet.</p><h2><strong>Install the Wanaku CLI</strong></h2><p>For a Mac tutorial, the JBang path is the simplest one because it avoids hardcoding a release ZIP names.</p><p>Install JBang:</p><pre><code><code>brew install jbangdev/tap/jbang
jbang version
jbang app setup</code></code></pre><p>Open a new terminal, or reload your shell so the JBang app path is available. After that, install Wanaku:</p><pre><code><code>jbang app install wanaku@wanaku-ai/wanaku
wanaku --version</code></code></pre><p>If you want to run this on Java 25, use <code>wanaku --help</code> to check if it works. In my run, <code>wanaku --version</code> did not.</p><h2><strong>Start Wanaku Locally</strong></h2><p>Before starting the stack, ask Wanaku which local services it can launch:</p><pre><code><code>wanaku start local --list-services</code></code></pre><p>In my run, the local service catalog was:</p><ul><li><p><code>service-http</code></p></li><li><p><code>service-tavily</code></p></li><li><p><code>service-exec</code></p></li></ul><p>That list matters later because it explains why my file resource example did not work.</p><p>Now start the local router:</p><pre><code><code>wanaku start local</code></code></pre><p>Keep that terminal open. Local mode disables authentication automatically, which is fine for a laptop demo and wrong for anything shared.</p><p>After startup, check the router health:</p><pre><code><code>curl -s http://127.0.0.1:8080/q/health
curl -s http://127.0.0.1:9000/q/health</code></code></pre><p>In my validation run, both the router on <code>8080</code> and the HTTP capability service on <code>9000</code> reported <code>UP</code>.</p><h2><strong>Import a First Toolset</strong></h2><p>The fastest way to put a real tool behind Wanaku is to import a toolset. I used the official currency example and listed the catalog against the explicit host so the output is predictable:</p><pre><code><code>wanaku tools import --plain --no-auth --host http://127.0.0.1:8080 \
  https://raw.githubusercontent.com/wanaku-ai/wanaku-toolsets/refs/heads/main/toolsets/currency.json

wanaku tools list --plain --no-auth --host http://127.0.0.1:8080</code></code></pre><p>Look for <code>free-currency-conversion-tool</code> in the output.</p><p>That part worked cleanly.</p><h2><strong>Bridge Another MCP Server into Wanaku</strong></h2><p>The next step is the one I really wanted to test. I wanted a real external MCP server sitting behind Wanaku so the router was doing more than wrapping HTTP.</p><p>For the validated path, I used the official Everything reference server in native SSE mode:</p><pre><code><code>npx -y @modelcontextprotocol/server-everything sse</code></code></pre><p>Keep that terminal open too.</p><p>In my run, the server started on port <code>3001</code> and logged:</p><pre><code><code>Starting SSE server...
Server is running on port 3001</code></code></pre><p>Now we need a namespace ID for the forward. This is one of the rough edges in the current CLI: <code>wanaku forwards add</code> wants a namespace ID. The easiest reproducible way I found was to reuse one of the preallocated local namespaces.</p><p>Extract the ID behind the <code>ns-1</code> path:</p><pre><code><code>WANAKU_NAMESPACE_ID=$(
  wanaku namespaces list --plain --no-auth --host http://127.0.0.1:8080 \
    | awk '$2 ~ /ns-1\\/mcp\\/sse$/ {print $1; exit}'
)

printf '%s\n' "$WANAKU_NAMESPACE_ID"</code></code></pre><p>Add the forward:</p><pre><code><code>wanaku forwards add --plain --no-auth --host http://127.0.0.1:8080 \
  -N "$WANAKU_NAMESPACE_ID" \
  --service="http://127.0.0.1:3001/sse" \
  --name local-everything-sse</code></code></pre><p>Then verify both the forward and the expanded tool catalog:</p><pre><code><code>wanaku forwards list --plain --no-auth --host http://127.0.0.1:8080
wanaku tools list --plain --no-auth --host http://127.0.0.1:8080</code></code></pre><p>In my run, <code>wanaku forwards list</code> showed:</p><pre><code><code>name                 address
local-everything-sse http://127.0.0.1:3001/sse</code></code></pre><p>And <code>wanaku tools list</code> showed the imported currency tool plus remote MCP tools such as:</p><ul><li><p><code>echo</code></p></li><li><p><code>get-sum</code></p></li><li><p><code>get-env</code></p></li><li><p><code>get-tiny-image</code></p></li><li><p><code>trigger-long-running-operation</code></p></li><li><p><code>simulate-research-query</code></p></li></ul><p>That is the fully working bridge: one Wanaku router, one local HTTP toolset, one external SSE MCP server, one merged catalog.</p><h2><strong>What This Proves</strong></h2><p>At this point, we have all up and running.</p><ul><li><p>Wanaku starts locally and serves a healthy router endpoint</p></li><li><p>Wanaku imports a normal HTTP toolset cleanly</p></li><li><p>Wanaku connects to a separate MCP server over SSE</p></li><li><p>Wanaku imports remote MCP tools into its own catalog under a namespace</p></li></ul><p>That is enough to show the routing model is real. </p><h2><strong>What Broke in My Run</strong></h2><p>Lessons learned so you don&#8217;t have to.</p><h3><strong>Filesystem Server Through Supergateway</strong></h3><p>I started with this bridge:</p><pre><code><code>npx -y supergateway \
  --stdio "npx -y @modelcontextprotocol/server-filesystem $HOME/wanaku-demo" \
  --port 8001 \
  --baseUrl http://127.0.0.1:8001</code></code></pre><p>That looks reasonable. It did not survive the live run.</p><p>What happened:</p><ul><li><p>Wanaku connected to the upstream SSE endpoint</p></li><li><p>The filesystem server asked for <code>roots/list</code></p></li><li><p>Wanaku replied with an empty roots list</p></li><li><p>The filesystem server exited with <code>No valid root directories provided by client</code></p></li><li><p>On the next connection attempt, Supergateway crashed with <code>Already connected to a transport</code></p></li></ul><p>So this did not happen for this article.</p><h3><strong>Memory Server Through Supergateway</strong></h3><p>I also tried the official memory server behind Supergateway:</p><pre><code><code>npx -y supergateway \
  --stdio "npx -y @modelcontextprotocol/server-memory" \
  --port 8002 \
  --baseUrl http://127.0.0.1:8002</code></code></pre><p>That failed differently. Wanaku probed <code>resources/list</code>, the server replied with <code>Method not found</code>, and Wanaku treated the upstream as unavailable.</p><p>That tells me the current forward path is more demanding than &#8220;any MCP server behind an SSE bridge.&#8221; I have not looked into this deeper to figure out who&#8217;s fault this is. But maybe I find some time and find out more.</p><h2><strong>The  Primitives, With the Current Rough Edges</strong></h2><p>The original idea I had for this article wanted to show tools and forwards as if they were equally smooth in local mode. They were not.</p><h3><strong>Tools</strong></h3><p>Tools are the cleanest part of the current local flow. The imported currency toolset worked immediately:</p><pre><code><code>wanaku tools import --plain --no-auth --host http://127.0.0.1:8080 \
  https://raw.githubusercontent.com/wanaku-ai/wanaku-toolsets/refs/heads/main/toolsets/currency.json</code></code></pre><p>If your backend already has an OpenAPI document, the next command to look at is:</p><pre><code><code>wanaku tools generate http://petstore3.swagger.io/api/v3/openapi.yaml</code></code></pre><p>That is where Wanaku stops looking like a demo wrapper and starts looking like a migration path.</p><h3><strong>Forwards</strong></h3><p>Forwards are the part that gives Wanaku real architectural value. They also have the sharpest edges right now.</p><p>The native SSE bridge to the Everything server worked:</p><pre><code><code>wanaku forwards add --plain --no-auth --host http://127.0.0.1:8080 \
  -N "$WANAKU_NAMESPACE_ID" \
  --service="http://127.0.0.1:3001/sse" \
  --name local-everything-sse</code></code></pre><p>The stdio-to-SSE bridges I tried were much less forgiving.</p><h2><strong>Why This Architecture Still Makes Sense</strong></h2><p>Even with the rough edges, the core idea still holds up.</p><p>Your agent does not need direct knowledge of every backend, every tool definition, every upstream MCP server, and every transport quirk. It needs one stable place to connect. Wanaku turns the rest into catalog management. Add a tool. Forward another MCP server. Keep the client side simpler.</p><p>The Apache Camel base matters too. Today the working example is an HTTP tool plus an upstream SSE MCP server. Tomorrow it can be a real Camel route, a Salesforce integration, or an internal API you already describe with OpenAPI. The routing model still makes sense even when a few local-mode features are rough.</p><h2><strong>What to Try Next</strong></h2><ul><li><p>Stay on Java 21 for now unless you want to carry the Java 25 JVM workaround.</p></li><li><p>Start with an upstream server that already speaks SSE if you want the cleanest first forward.</p></li><li><p>Treat stdio-to-SSE bridges as something to validate case by case, especially when the upstream server depends on roots or does not implement <code>resources/list</code>.</p></li><li><p>Revisit file resources and prompt registration after Wanaku local mode exposes the right providers and the current prompt persistence error is fixed.</p></li></ul><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[AI Agents and the Rockstar Developer Problem]]></title><description><![CDATA[Generated code arrives fast, but teams still need shared design, local conventions, change budgets, and review gates before output becomes software they can own.]]></description><link>https://www.the-main-thread.com/p/ai-agents-rockstar-developer-problem</link><guid isPermaLink="false">https://www.the-main-thread.com/p/ai-agents-rockstar-developer-problem</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Tue, 07 Jul 2026 06:08:08 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/bd3066d7-958a-469e-856b-78a4ca6f5cfd_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every software team eventually meets some version of this old failure mode: one very capable developer builds a system that only they can really explain.</p><p>The details change from company to company. Sometimes it is a custom framework that exists because one person got bored with the boring one. Sometimes it is a build system with three hidden assumptions and one person who knows the ritual. Sometimes it is a beautiful abstraction that looks like serious engineering until the first bug report arrives and nobody can explain how data moves through it.</p><p>Smart was fine. Smart helped.</p><p>The failure was unshared design.</p><p>The architecture lived in one head. The code fit the person writing it, not the people who had to change it later. Code review stopped helping because nobody wanted to say they did not understand the impressive part. Eventually the person left, changed teams, lost interest, or became too busy to explain the system they had created.</p><p>Then the team discovered the actual architecture document: production.</p><p>AI agents are making that old problem cheap, fast, and repeatable.</p><p>As I have written in <a href="https://www.the-main-thread.com/p/ai-coding-real-systems">AI Coding in Real Systems: Code Is Cheap. Software Isn&#8217;t</a>, the code arrives faster now. The ownership, verification, and integration costs still arrive on the team&#8217;s side of the ledger.</p><h2><strong>The New Rockstar Forgets Yesterday</strong></h2><p>A human rockstar, for all the damage one can do, at least has continuity. They remember the argument that produced the abstraction. They remember why the service boundary landed there. They remember which bug made them add the defensive layer everyone else now has to step around.</p><p>A continuous mind sat behind the mess. That did not make the code maintainable, but it at least gave the team one place to ask why the system looked like that.</p><p>An agent session has no durable ownership unless the team builds it. It can generate a clever solution in the morning, generate a different style of clever solution in the afternoon, and then confidently refactor both tomorrow from a partial view of the repository.</p><p>The old rockstar developer created a private architecture.</p><p>The AI version creates a series of private architectures, one chat at a time.</p><p>Each individual change can look reasonable. The tests pass. The code compiles. The explanation sounds calm. The agent used a known pattern, maybe even a good one. But the system slowly collects incompatible ideas:</p><ul><li><p>two ways to model errors</p></li><li><p>three ways to configure clients</p></li><li><p>a new helper layer around a framework that already had the helper</p></li><li><p>a validation style from one tutorial</p></li><li><p>a reactive pattern in one endpoint because the model saw it somewhere</p></li><li><p>a security check that is correct locally and meaningless in the real request path</p></li></ul><p>None of those has to be catastrophic alone. That is why the failure mode is hard to see early. The cost shows up when the next developer has to change the system and finds five small architectures pretending to be one application.</p><p>This is not a model-quality complaint per se. Better models will reduce some of the local mistakes. They will not remove the need for a coherent engineering style.</p><p>That is also why broad &#8220;language support&#8221; claims are not the solution here. <a href="https://www.the-main-thread.com/p/code-llm-language-support">What Code LLM Vendors Usually Mean by &#8220;Language Support&#8221;</a> splits that into a ladder: syntax is one thing, framework fit is another, and workflow support is where serious teams start caring.</p><h2><strong>Consistency Is Operational Memory</strong></h2><p>Developers like to talk about consistency as if it were polish. Naming, layout, dependency choices, exception patterns, logging, tests, configuration, HTTP client usage. These sound like small matters until a team is under pressure.</p><p>Under pressure, consistency becomes operational memory.</p><p>If every Quarkus resource handles failures the same way, you know where to look. If every service uses typed configuration the same way, you know how a setting reaches runtime. If tests follow a predictable shape, you can add one without rediscovering the local habits. If logging uses one library and one style, incident review is less annoying than it already has to be.</p><p>The boring shape of a codebase is part of how a team thinks together.</p><p>AI agents do not naturally respect that shape. They respect the context they see, the instructions they receive, the examples closest to the current task, and the statistical gravity of all the code they were trained on. That means they can be fast, helpful, and slightly too willing to solve the problem they inferred instead of the problem this system has.</p><ul><li><p>If the repo has no visible rules, the agent invents a style.</p></li><li><p>If the prompt has no boundaries, the agent expands the task.</p></li><li><p>If the review only checks whether the output works, the architecture drifts.</p></li></ul><p>That is not the agent being malicious or stupid. It is doing what we asked badly enough.</p><h2><strong>The Agent Is Not a Teammate</strong></h2><p>I use agents. I like them. Hand-written code has no moral purity here. Plenty of terrible code was typed by humans with full confidence and a mechanical keyboard.</p><p>But teams get into trouble when they treat the agent like a teammate and then forget the parts of teamwork that make software survivable.</p><p>A teammate carries memory. A teammate can be responsible for a design after the merge. A teammate can sit in the incident review and explain the trade-off. A teammate can learn the local codebase over years and internalize what the team does not want repeated.</p><p>An agent can help with all of that, but it does not own any of it.</p><p>Ownership still lands on the humans.</p><p>That changes how I want teams to use these tools. The agent should not act like a replacement senior engineer with unlimited permission to reshape the codebase. It should work inside clear boundaries:</p><ul><li><p>clear task scope</p></li><li><p>visible local conventions</p></li><li><p>small changes</p></li><li><p>tests that describe the expected behavior</p></li><li><p>review that checks integration, not only syntax</p></li><li><p>a human who can explain why the final version belongs in this system</p></li></ul><p>That slows the tool down enough for the team to stay with it. Some slowness is not waste. Some slowness is the part where understanding catches up with output.</p><h2><strong>The Senior Job Moves Toward Taste and Control</strong></h2><p>Senior engineering judgment moves upstream and sideways in the agent era.</p><p>Less time typing boilerplate can be good. I am happy to spend less of my life writing the tenth shape of the same DTO mapping. But if implementation gets cheaper, the important question changes from &#8220;can we build it?&#8221; to &#8220;what shape should it have, and how do we keep that shape over time?&#8221;</p><p>That is architecture. Not the slide-deck version. The daily version.</p><ul><li><p>Which framework feature should we use before adding our own layer?</p></li><li><p>Which abstraction is too clever for this problem?</p></li><li><p>Which generated test proves behavior, and which one only proves that the mock was configured?</p></li><li><p>Which local convention should be written down because the agent keeps missing it?</p></li><li><p>Which change should be split because the diff is now too large for meaningful review?</p></li></ul><p>That last question has its own version in <a href="https://www.the-main-thread.com/p/coding-agents-change-budget">Give Coding Agents a Change Budget</a>. A change can be technically valid and still too large for a human reviewer to own in one pass.</p><p>That work can feel less glamorous than watching a tool generate a full feature in one pass. It is also the work that keeps a team from drowning in plausible code.</p><p>AI raises the value of taste because it lowers the cost of producing tasteless volume.</p><p>Taste here does not mean personal preference with seniority attached to it. It means choosing the plain thing that fits the system, rejecting the impressive thing that does not, and explaining the difference without turning review into a debate club.</p><h2><strong>Code Nobody Understands Is Still Debt</strong></h2><p>One of the more dangerous habits in agent-assisted development is accepting code because the agent can explain it.</p><p>That is a low bar.</p><p>The agent can explain almost anything after the fact. The explanation may even be accurate. But a generated explanation is not the same as team understanding. If the humans cannot maintain the code without reopening the chat and asking the same system to interpret itself, the team has taken on a new dependency.</p><p>Sometimes that dependency is fine. We already depend on tools. We depend on compilers, IDEs, search, docs, static analysis, and people with better memory than ours. But depending on an agent to make sense of architecture the team never understood is a bad trade.</p><p>It creates a codebase that can only be maintained through the same mechanism that made it confusing. A team can live there for a while, but it is an expensive place to maintain software.</p><p>This is also where <a href="https://www.the-main-thread.com/p/spec-trap-agent-work">The Spec Trap</a> comes back in. If the intent never became concrete enough for a human or a test to check, the implementation can pass the written prompt and still miss the system.</p><p>The healthier pattern is simple: use the agent to accelerate work the team can still own.</p><p>Let it draft the boring parts. Let it find similar code. Let it write the first test. Let it propose a refactor. Let it explain an unfamiliar dependency. Let it handle mechanical edits where the rules are clear. You can even brainstorm implementation ideas and evaluate their pros- and cons.</p><p>Then slow down at the boundary where the code changes the architecture, the runtime behavior, the security model, or the team&#8217;s future review burden.</p><p>That boundary is where the rockstar problem starts.</p><h2><strong>Write Down the Local Rules</strong></h2><p>Banning agents would age badly and waste a lot of useful leverage.</p><p>The practical answer is to make the team&#8217;s engineering taste visible enough that an agent can work inside it.</p><p>That means plain files with plain names. <code>AGENTS.md</code>. <code>CONTRIBUTING.md</code>. Local coding rules. Architecture decision records when the decision matters. Examples of the pattern you want copied. Tests that fail when the agent improvises in the wrong place. Small prompts that tell the agent the shape of the task before it starts inventing a better world.</p><p>This can look like process until it saves a review. It is context engineering for codebases. The more operational version of that idea is <a href="https://www.the-main-thread.com/p/coding-agent-operating-manual">AGENTS.md: Give Your Coding Agent an Operating Manual</a>.</p><p>If you do not give the agent a local operating model, it will bring one from somewhere else. Usually several. Usually with unjustified confidence.</p><p>The same applies to review. Generated code needs a fit check before it gets a cleverness check. The first question is whether the change belongs.</p><ul><li><p>Does it follow the framework&#8217;s standard path?</p></li><li><p>Does it use the same configuration model as the rest of the service?</p></li><li><p>Does it add an abstraction because the system needs one, or because the model has seen that shape often?</p></li><li><p>Can the next developer change this without asking an LLM to narrate the maze?</p></li></ul><p>If the answer is no, the code still needs work, even if every test is green.</p><p>For the repository side of this, I would pair that operating manual with <a href="https://www.the-main-thread.com/p/coding-agent-guardrails">Coding Agent Guardrails: Treat Every Commit as Untrusted</a>. Prompt discipline helps, but the repo still needs hooks, CI, protected paths, and merge boundaries that can say no.</p><h2><strong>Keep the Code Boring Enough to Survive</strong></h2><p>The old rockstar developer left behind code that made the rest of the team feel slower and less informed.</p><p>The AI version can do the same thing at a much larger scale. It can make everyone feel productive while the codebase becomes harder to reason about. It can make architecture drift look like velocity. It can make review feel unnecessary because the output is clean, formatted, and accompanied by a confident explanation.</p><p>That is the trap.</p><p>Generated code should still be team code. It should follow the same conventions, carry the same tests, fit the same architecture, and be understood by the people who will wake up when it breaks.</p><p>Agents are useful. Speed is useful. Fast drafts are useful.</p><div class="pullquote"><p>But the work is not finished when the agent stops editing files. The work is finished when the team can own what changed.</p></div><p>That is the part the rockstar developer never optimized for.</p><p>It is the part we cannot outsource now.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Build a Similar-Incident Finder with Quarkus and Qdrant]]></title><description><![CDATA[Turn Java incidents into vectors with deterministic feature hashing, store them in Qdrant, and find the nearest past failures when production breaks.]]></description><link>https://www.the-main-thread.com/p/incident-vector-search</link><guid isPermaLink="false">https://www.the-main-thread.com/p/incident-vector-search</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Mon, 06 Jul 2026 06:09:10 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ded2e236-ac2a-4b6a-bcf0-da878f0d92f2_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A production incident usually starts with one boring question:</p><pre><code><code>Have we seen this failure before?</code></code></pre><p>The answer is often somewhere in an old incident report, a Slack thread, a ticket, or a runbook. The text is there. The problem is shape. A new stack trace does not match the old one character for character. The service moved packages, the line numbers changed, and the error message gained one extra adjective because software enjoys small acts of drama.</p><p>Keyword search helps when the same words repeat. It misses cases where the failure looks similar but the text is not identical. Full AI retrieval can help too, but it brings model keys, embedding providers, latency, and a different operational story. Also we all are tired of all of this somedays. So for this article I want the smaller tool: turn a Java incident into a vector, store it in Qdrant, and ask for the nearest old failures.</p><p>No LLM is required or even used. We use deterministic feature hashing in plain Java. That makes the vectors inspectable and repeatable. Qdrant gives us similarity search, payload filters, Dev Services, and a normal Quarkus client.</p><h2><strong>What We Build</strong></h2><p>Incident Vector Search is a Quarkus service with three endpoints:</p><ul><li><p><code>POST /incidents/seed</code> loads five example incidents</p></li><li><p><code>POST /incidents</code> indexes one incident</p></li><li><p><code>POST /incidents/search</code> searches for similar incidents</p></li></ul><p>The app stores each incident as a <a href="https://qdrant.tech/documentation/manage-data/points/">Qdrant point</a>: one vector plus payload. The vector captures the rough shape of the failure. The payload keeps the fields humans still care about: service, environment, exception type, message, resolution, and incident URL.</p><p>Two Qdrant details matter early:</p><ul><li><p>A collection has a fixed vector size and distance metric</p></li><li><p>A point ID must be a 64-bit unsigned integer or a UUID</p></li></ul><p>That second detail is easy to miss. Our business IDs look like <code>INC-1001</code>, so we keep those in payload and use deterministic UUIDs as Qdrant point IDs.</p><h2><strong>What You Need</strong></h2><p>You need a normal Quarkus setup and a container runtime for Qdrant Dev Services.</p><ul><li><p>JDK 25</p></li><li><p><a href="https://quarkus.io/guides/cli-tooling">Quarkus CLI</a></p></li><li><p>Podman or another Docker-compatible container runtime</p></li><li><p><code>curl</code> and <code>jq</code></p></li><li><p>About two &#9749;&#65039;</p></li></ul><p>This article uses Quarkus 3.37.0, Java 25, <code>io.quarkiverse.qdrant:quarkus-qdrant:0.1.0</code>, and Qdrant <code>v1.18-unprivileged</code> for Dev Services.</p><h2><strong>Create The Project</strong></h2><p>Create the base app and follow along or <a href="https://github.com/myfear/the-main-thread/tree/main/incident-vector-search">grab the repository from my Github</a>:</p><pre><code><code>quarkus create app dev.mainthread:incident-vector-search \
  --extension=rest-jackson,smallrye-health \
  --java=25 \
  --no-code</code></code></pre><p>Use these extensions:</p><ul><li><p><code>quarkus-rest-jackson</code>: JSON REST endpoints</p></li><li><p><code>quarkus-smallrye-health</code>: readiness checks, including the Qdrant extension check</p></li></ul><p>Add Qdrant and validation to <code>pom.xml</code>:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.quarkiverse.qdrant&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-qdrant&lt;/artifactId&gt;
    &lt;version&gt;0.1.0&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;io.quarkus&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-hibernate-validator&lt;/artifactId&gt;
&lt;/dependency&gt;</code></code></pre><p><code>quarkus-qdrant</code> gives us an injectable <code>QdrantClient</code>. Hibernate Validator keeps bad incident documents out of the application code path. A missing stack trace should fail as HTTP 400, not as a weak vector that pollutes the index.</p><h2><strong>Configure Qdrant Dev Services</strong></h2><p>Create <code>src/main/resources/application.properties</code>:</p><pre><code><code>quarkus.qdrant.devservices.qdrant-image-name=docker.io/qdrant/qdrant:v1.18-unprivileged
quarkus.qdrant.devservices.collections.incidents.vector-size=384
quarkus.qdrant.devservices.collections.incidents.distance=Cosine

quarkus.smallrye-health.root-path=/q/health</code></code></pre><p>Qdrant starts automatically in dev and test mode. The extension also creates the <code>incidents</code> collection before the app starts.</p><p><code>vector-size=384</code> is arbitrary but fixed. Every vector we write must have exactly 384 values. Change it later and old points no longer fit the collection. That is not a warning for Qdrant specifically; it is how vector collections work.</p><p><code>distance=Cosine</code> compares direction more than magnitude. Since we normalize every vector to length one, cosine similarity is a good fit for this example.</p><h2><strong>Define The Incident Shape</strong></h2><p>Start with one request record:</p><pre><code><code>package dev.mainthread.incidents;

import java.util.List;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

public record IncidentInput(
        String id,
        @NotBlank String service,
        @NotBlank String environment,
        @NotBlank String exceptionType,
        @NotBlank String message,
        @NotEmpty @Size(max = 20) List&lt;@NotBlank String&gt; stackTrace,
        String resolvedBy,
        String incidentUrl) {
}</code></code></pre><p>There is no database entity here. Qdrant stores the vector and payload. The record is the HTTP boundary and the indexing input.</p><p>The stack trace limit is intentionally small. We only need the top frames for this kind of matching. If you let clients send 5,000 frames, they will. Then you get worse vectors and a free denial-of-service shape. Very generous of them.</p><p>The search request wraps an incident and adds search controls:</p><pre><code><code>package dev.mainthread.incidents;

import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

public record SimilarIncidentRequest(
        @NotNull @Valid IncidentInput incident,
        @Min(1) @Max(20) Integer limit,
        @DecimalMin("0.0") @DecimalMax("1.0") Float minScore,
        String filterService,
        String filterEnvironment,
        Boolean onlyResolved) {
}</code></code></pre><p>The filters are not part of the vector. They stay explicit because they are business constraints. A checkout failure may look like a billing failure at the stack-trace level. If the operator asks for checkout incidents in prod, Qdrant should only search that slice.</p><p>Add the response records too:</p><pre><code><code>package dev.mainthread.incidents;

public record IndexedIncident(String id, String pointId, String collection, int dimensions) {
}</code></code></pre><pre><code><code>package dev.mainthread.incidents;

public record IncidentMatch(
        String id,
        float score,
        String service,
        String environment,
        String exceptionType,
        String message,
        String resolvedBy,
        String incidentUrl) {
}</code></code></pre><pre><code><code>package dev.mainthread.incidents;

import java.util.List;

public record SearchResponse(int count, List&lt;IncidentMatch&gt; matches) {
}</code></code></pre><pre><code><code>package dev.mainthread.incidents;

import java.util.List;

public record SeedResponse(int indexed, List&lt;String&gt; ids) {
}</code></code></pre><p>Now the boundary is clear. The API speaks in incident IDs like <code>INC-1001</code>; Qdrant receives UUID point IDs behind the service.</p><h2><strong>Turn Incidents Into Vectors</strong></h2><p>Feature hashing is the small trick in this app. We take tokens from the incident, hash each token into one of 384 buckets, add weighted counts, and normalize the result.</p><p>This is not a semantic embedding. It will not learn that &#8220;database pool exhausted&#8221; and &#8220;Agroal acquisition timeout&#8221; mean related things unless the tokens overlap. That is fine for this article. We want a simple vectorizer where every score is explainable from the input.</p><p>Create <code>IncidentVectorizer</code>:</p><pre><code><code>package dev.mainthread.incidents;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
class IncidentVectorizer {

    static final int DIMENSIONS = 384;

    private static final Pattern TOKEN = Pattern.compile("[a-z0-9]+");

    List&lt;Float&gt; vectorForPoint(IncidentInput incident) {
        float[] values = vectorForSearch(incident);
        List&lt;Float&gt; boxed = new ArrayList&lt;&gt;(values.length);
        for (float value : values) {
            boxed.add(value);
        }
        return boxed;
    }

    float[] vectorForSearch(IncidentInput incident) {
        float[] vector = new float[DIMENSIONS];
        addWeighted(vector, "service:" + incident.service(), 5);
        addWeighted(vector, "environment:" + incident.environment(), 2);
        addWeighted(vector, "exception:" + incident.exceptionType(), 6);
        addText(vector, incident.exceptionType(), 3);
        addText(vector, incident.message(), 2);

        for (String frame : incident.stackTrace()) {
            addWeighted(vector, "frame:" + frame, 3);
            addText(vector, frame, 1);
        }

        normalize(vector);
        return vector;
    }

    double cosine(float[] left, float[] right) {
        double dot = 0;
        double leftNorm = 0;
        double rightNorm = 0;
        for (int i = 0; i &lt; left.length; i++) {
            dot += left[i] * right[i];
            leftNorm += left[i] * left[i];
            rightNorm += right[i] * right[i];
        }
        if (leftNorm == 0 || rightNorm == 0) {
            return 0;
        }
        return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
    }

    private void addText(float[] vector, String text, int weight) {
        if (text == null || text.isBlank()) {
            return;
        }

        Matcher matcher = TOKEN.matcher(text.toLowerCase(Locale.ROOT));
        while (matcher.find()) {
            addWeighted(vector, matcher.group(), weight);
        }
    }

    private void addWeighted(float[] vector, String rawToken, int weight) {
        if (rawToken == null || rawToken.isBlank()) {
            return;
        }

        String token = rawToken.toLowerCase(Locale.ROOT).trim();
        int bucket = Math.floorMod(token.hashCode(), vector.length);
        vector[bucket] += weight;
    }

    private void normalize(float[] vector) {
        double length = 0;
        for (float value : vector) {
            length += value * value;
        }
        if (length == 0) {
            return;
        }

        float norm = (float) Math.sqrt(length);
        for (int i = 0; i &lt; vector.length; i++) {
            vector[i] = vector[i] / norm;
        }
    }
}</code></code></pre><p>Notice the weights. <code>exceptionType</code> and exact top frames matter more than general message words. <code>service</code> matters too, but we still keep service as a filter later. The vector says &#8220;these incidents have a similar failure shape.&#8221; The filter says &#8220;only search the operational slice I asked for.&#8221;</p><p>Before you continue, predict one thing: what happens if we remove normalization?</p><p>Longer stack traces start to score higher because they add more token counts. Normalization keeps the vector length stable, so the score is about overlap and direction rather than document size.</p><h2><strong>Add Seed Incidents</strong></h2><p>Create a small fixture class. These are realistic enough to make the first search interesting:</p><pre><code><code>package dev.mainthread.incidents;

import java.util.List;

final class IncidentFixtures {

    private IncidentFixtures() {
    }

    static List&lt;IncidentInput&gt; examples() {
        return List.of(
                new IncidentInput(
                        "INC-1001",
                        "checkout-service",
                        "prod",
                        "java.lang.NullPointerException",
                        "Cannot invoke DiscountPolicy.percentage because policy is null while applying coupon",
                        List.of(
                                "dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:84)",
                                "dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)",
                                "dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"),
                        "Guard missing coupon policy before discount calculation",
                        "https://runbooks.example.com/incidents/INC-1001"),
                new IncidentInput(
                        "INC-1002",
                        "billing-service",
                        "prod",
                        "java.sql.SQLTransientConnectionException",
                        "Timed out waiting for database connection from the invoice pool",
                        List.of(
                                "dev.mainthread.billing.InvoiceRepository.findOpenInvoices(InvoiceRepository.java:118)",
                                "dev.mainthread.billing.InvoiceBatch.closeCurrentPeriod(InvoiceBatch.java:55)",
                                "io.agroal.pool.ConnectionPool.handlerFromSharedCache(ConnectionPool.java:321)"),
                        "Increase pool timeout and split invoice close job into smaller batches",
                        "https://runbooks.example.com/incidents/INC-1002"),
                new IncidentInput(
                        "INC-1003",
                        "checkout-service",
                        "prod",
                        "java.util.concurrent.TimeoutException",
                        "Tax service call exceeded the 800 ms client timeout during payment authorization",
                        List.of(
                                "dev.mainthread.checkout.TaxClient.calculate(TaxClient.java:62)",
                                "dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:52)",
                                "dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"),
                        "Cache tax jurisdiction lookups and fail payment before authorization",
                        "https://runbooks.example.com/incidents/INC-1003"),
                new IncidentInput(
                        "INC-1004",
                        "inventory-service",
                        "staging",
                        "jakarta.persistence.OptimisticLockException",
                        "Concurrent stock reservation updated the same SKU version",
                        List.of(
                                "dev.mainthread.inventory.StockRepository.reserve(StockRepository.java:77)",
                                "dev.mainthread.inventory.ReservationService.reserveCart(ReservationService.java:41)",
                                "dev.mainthread.inventory.InventoryResource.reserve(InventoryResource.java:29)"),
                        "Retry stock reservation once and return conflict after the second collision",
                        "https://runbooks.example.com/incidents/INC-1004"),
                new IncidentInput(
                        "INC-1005",
                        "search-service",
                        "prod",
                        "com.fasterxml.jackson.databind.JsonMappingException",
                        "Cannot deserialize shipment filter because status contains an unknown enum value",
                        List.of(
                                "dev.mainthread.search.ShipmentSearchResource.search(ShipmentSearchResource.java:37)",
                                "com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3895)",
                                "io.quarkus.resteasy.reactive.jackson.runtime.serialisers.ServerJacksonMessageBodyReader.readFrom(ServerJacksonMessageBodyReader.java:92)"),
                        "Reject unknown status values at the API boundary",
                        "https://runbooks.example.com/incidents/INC-1005"));
    }
}</code></code></pre><p>The sample set has two checkout incidents. One is a <code>NullPointerException</code>, the other is a timeout. That gives the search a real choice: same service is not enough. Similar failure shape should win.</p><h2><strong>Store Incidents In Qdrant</strong></h2><p>Now wrap the Qdrant client in one application service:</p><pre><code><code>package dev.mainthread.incidents;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import io.quarkiverse.qdrant.runtime.QdrantClient;
import io.quarkiverse.qdrant.runtime.model.PointStruct;
import io.quarkiverse.qdrant.runtime.model.ScoredPoint;

@ApplicationScoped
class IncidentArchive {

    private static final String COLLECTION = "incidents";
    private static final int DEFAULT_LIMIT = 5;
    private static final float DEFAULT_MIN_SCORE = 0.68f;

    private final QdrantClient qdrant;
    private final IncidentVectorizer vectorizer;
    private volatile boolean collectionChecked;

    @Inject
    IncidentArchive(QdrantClient qdrant, IncidentVectorizer vectorizer) {
        this.qdrant = qdrant;
        this.vectorizer = vectorizer;
    }

    IndexedIncident index(IncidentInput incident) {
        ensureCollection();
        String id = incidentId(incident);
        String pointId = pointIdFor(incident);
        qdrant.upsert(COLLECTION)
                .point(new PointStruct(pointId, vectorizer.vectorForPoint(incident), payloadFor(id, incident)))
                .execute();
        return new IndexedIncident(id, pointId, COLLECTION, IncidentVectorizer.DIMENSIONS);
    }

    SeedResponse seed(List&lt;IncidentInput&gt; incidents) {
        ensureCollection();
        List&lt;String&gt; ids = new ArrayList&lt;&gt;(incidents.size());
        List&lt;PointStruct&gt; points = new ArrayList&lt;&gt;(incidents.size());

        for (IncidentInput incident : incidents) {
            String id = incidentId(incident);
            String pointId = pointIdFor(incident);
            ids.add(id);
            points.add(new PointStruct(pointId, vectorizer.vectorForPoint(incident), payloadFor(id, incident)));
        }

        qdrant.upsert(COLLECTION).points(points).execute();
        return new SeedResponse(points.size(), ids);
    }

    SearchResponse search(SimilarIncidentRequest request) {
        ensureCollection();
        int limit = request.limit() == null ? DEFAULT_LIMIT : request.limit();
        float minScore = request.minScore() == null ? DEFAULT_MIN_SCORE : request.minScore();

        List&lt;ScoredPoint&gt; points = qdrant.search(COLLECTION)
                .vector(vectorizer.vectorForSearch(request.incident()))
                .limit(limit)
                .scoreThreshold(minScore)
                .withPayload(true)
                .withVector(false)
                .filter(filterFor(request))
                .execute();

        List&lt;IncidentMatch&gt; matches = points.stream()
                .map(IncidentArchive::matchFrom)
                .toList();
        return new SearchResponse(matches.size(), matches);
    }

    private synchronized void ensureCollection() {
        if (collectionChecked) {
            return;
        }
        if (!qdrant.listCollections().contains(COLLECTION)) {
            qdrant.createCollection(COLLECTION)
                    .vectorSize(IncidentVectorizer.DIMENSIONS)
                    .distance("Cosine")
                    .execute();
        }
        collectionChecked = true;
    }

    private static String incidentId(IncidentInput incident) {
        if (incident.id() != null &amp;&amp; !incident.id().isBlank()) {
            return incident.id();
        }
        return pointIdFor(incident);
    }

    private static String pointIdFor(IncidentInput incident) {
        String source = incident.service() + "|" + incident.environment() + "|" + incident.exceptionType() + "|"
                + incident.message() + "|" + incident.stackTrace();
        if (incident.id() != null &amp;&amp; !incident.id().isBlank()) {
            source = incident.id() + "|" + source;
        }
        return UUID.nameUUIDFromBytes(source.getBytes(StandardCharsets.UTF_8)).toString();
    }

    private static Map&lt;String, Object&gt; payloadFor(String id, IncidentInput incident) {
        Map&lt;String, Object&gt; payload = new LinkedHashMap&lt;&gt;();
        payload.put("id", id);
        payload.put("pointId", pointIdFor(incident));
        payload.put("service", incident.service());
        payload.put("environment", incident.environment());
        payload.put("exceptionType", incident.exceptionType());
        payload.put("message", incident.message());
        payload.put("stackTrace", incident.stackTrace());
        payload.put("resolved", incident.resolvedBy() != null &amp;&amp; !incident.resolvedBy().isBlank());
        putIfPresent(payload, "resolvedBy", incident.resolvedBy());
        putIfPresent(payload, "incidentUrl", incident.incidentUrl());
        return payload;
    }

    private static Map&lt;String, Object&gt; filterFor(SimilarIncidentRequest request) {
        List&lt;Map&lt;String, Object&gt;&gt; must = new ArrayList&lt;&gt;();
        addMatch(must, "service", request.filterService());
        addMatch(must, "environment", request.filterEnvironment());
        if (Boolean.TRUE.equals(request.onlyResolved())) {
            addMatch(must, "resolved", true);
        }
        if (must.isEmpty()) {
            return null;
        }
        return Map.of("must", must);
    }

    private static void addMatch(List&lt;Map&lt;String, Object&gt;&gt; must, String key, Object value) {
        if (value == null) {
            return;
        }
        if (value instanceof String text &amp;&amp; text.isBlank()) {
            return;
        }
        must.add(Map.of("key", key, "match", Map.of("value", value)));
    }

    private static void putIfPresent(Map&lt;String, Object&gt; payload, String key, String value) {
        if (value != null &amp;&amp; !value.isBlank()) {
            payload.put(key, value);
        }
    }

    private static IncidentMatch matchFrom(ScoredPoint point) {
        Map&lt;String, Object&gt; payload = point.getPayload();
        return new IncidentMatch(
                valueOrFallback(payload, "id", point.getId()),
                point.getScore(),
                stringValue(payload, "service"),
                stringValue(payload, "environment"),
                stringValue(payload, "exceptionType"),
                stringValue(payload, "message"),
                stringValue(payload, "resolvedBy"),
                stringValue(payload, "incidentUrl"));
    }

    private static String stringValue(Map&lt;String, Object&gt; payload, String key) {
        Object value = payload == null ? null : payload.get(key);
        return value == null ? null : value.toString();
    }

    private static String valueOrFallback(Map&lt;String, Object&gt; payload, String key, String fallback) {
        String value = stringValue(payload, key);
        return value == null ? fallback : value;
    }
}</code></code></pre><p>There are three decisions in this class worth keeping visible.</p><p>First, <code>ensureCollection()</code> checks the collection once. Dev Services already creates it for local runs, but the app can still create it when pointed at a fresh Qdrant instance. In production I would move collection creation to provisioning or migration code. Runtime creation is convenient for this hands-on path, not a full operations plan.</p><p>Second, <code>pointIdFor()</code> generates a deterministic UUID. Qdrant point loading is idempotent, so re-seeding the same incident overwrites the same point. That is exactly what you want when the input comes from a queue or retrying job.</p><p>Third, <code>filterFor()</code> builds Qdrant&#8217;s payload filter as a <code>Map</code>. The Quarkiverse client gives us a fluent search API and accepts raw filter JSON shape. For this article that is enough: match service, environment, and resolved status.</p><h2><strong>Expose The REST Endpoints</strong></h2><p>The resource stays thin:</p><pre><code><code>package dev.mainthread.incidents;

import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/incidents")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class IncidentResource {

    private final IncidentArchive archive;

    @Inject
    IncidentResource(IncidentArchive archive) {
        this.archive = archive;
    }

    @POST
    public IndexedIncident index(@Valid IncidentInput incident) {
        return archive.index(incident);
    }

    @POST
    @Path("/search")
    public SearchResponse search(@Valid SimilarIncidentRequest request) {
        return archive.search(request);
    }

    @POST
    @Path("/seed")
    @Consumes(MediaType.WILDCARD)
    public SeedResponse seed() {
        return archive.seed(IncidentFixtures.examples());
    }
}</code></code></pre><p><code>@Valid</code> is the useful part here. It makes Quarkus reject incomplete incident documents before the vectorizer sees them. The seed endpoint accepts an empty POST because it has no request body. Without the method-level <code>@Consumes(MediaType.WILDCARD)</code>, some clients send a default form content type and get a 415. That is a small test-discovered annoyance, so we remove it.</p><h2><strong>Run It</strong></h2><p>Start dev mode:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>On first run, Qdrant Dev Services pulls the Qdrant image and starts a container. After the app starts, seed the archive:</p><pre><code><code>curl -s -X POST http://localhost:8080/incidents/seed | jq </code></code></pre><p>Expected output:</p><pre><code><code>{
  "ids": [
    "INC-1001",
    "INC-1002",
    "INC-1003",
    "INC-1004",
    "INC-1005"
  ],
  "indexed": 5
}</code></code></pre><p>Now search with a new checkout failure:</p><pre><code><code>curl -s -X POST http://localhost:8080/incidents/search \
  -H "Content-Type: application/json" \
  -d '{
    "incident": {
      "service": "checkout-service",
      "environment": "prod",
      "exceptionType": "java.lang.NullPointerException",
      "message": "Cannot invoke DiscountPolicy.percentage because policy is null while pricing cart",
      "stackTrace": [
        "dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:91)",
        "dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)",
        "dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"
      ]
    },
    "limit": 3,
    "minScore": 0.60,
    "filterService": "checkout-service",
    "filterEnvironment": "prod",
    "onlyResolved": true
  }' | jq .</code></code></pre><p>Expected shape:</p><pre><code><code>{
  "count": 1,
  "matches": [
    {
      "environment": "prod",
      "exceptionType": "java.lang.NullPointerException",
      "id": "INC-1001",
      "incidentUrl": "https://runbooks.example.com/incidents/INC-1001",
      "message": "Cannot invoke DiscountPolicy.percentage because policy is null while applying coupon",
      "resolvedBy": "Guard missing coupon policy before discount calculation",
      "score": 0.93308544,
      "service": "checkout-service"
    }
  ]
}</code></code></pre><p>The exact score can move if you change the vectorizer weights or sample data. The first ID should stay <code>INC-1001</code>. That is the behavior we care about.</p><p>Now try the same search without the service and environment filters. You may still get the right result first, but Qdrant is free to compare against all services. Add the filters back and the search becomes the shape an operator expects: similar incident, same service, same environment, already resolved.</p><p>That is the part of Qdrant I like for this use case. Vector search handles fuzzy similarity. Payload filtering keeps operational policy explicit.</p><h2><strong>Check Readiness</strong></h2><p>Add the health extension and the Qdrant extension registers a readiness check automatically:</p><pre><code><code>curl -s http://localhost:8080/q/health/ready | jq .</code></code></pre><p>Expected shape:</p><pre><code><code>{
  "status": "UP",
  "checks": [
    {
      "name": "Qdrant REST Client health check",
      "status": "UP"
    }
  ]
}</code></code></pre><p>If Qdrant is down, readiness should fail. That is the right failure mode for this app. Serving incident search without the vector store would give operators an empty answer at the worst possible time.</p><p>For an external Qdrant instance, configure the client like this:</p><pre><code><code>quarkus.qdrant.host=qdrant.example.com
quarkus.qdrant.port=6333
quarkus.qdrant.api-key=${QDRANT_API_KEY}
quarkus.qdrant.use-tls=true</code></code></pre><p>Setting <code>quarkus.qdrant.host</code> disables Dev Services. Local runs get a container. Production gets the configured service.</p><h2><strong>Test The Behavior</strong></h2><p>The unit test checks the vectorizer before Qdrant enters the picture:</p><pre><code><code>package dev.mainthread.incidents;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;

import org.junit.jupiter.api.Test;

class IncidentVectorizerTest {

    private final IncidentVectorizer vectorizer = new IncidentVectorizer();

    @Test
    void similarCheckoutFailuresScoreHigherThanUnrelatedBillingFailures() {
        IncidentInput query = new IncidentInput(
                null,
                "checkout-service",
                "prod",
                "java.lang.NullPointerException",
                "Cannot invoke DiscountPolicy.percentage because policy is null",
                List.of(
                        "dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:91)",
                        "dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)"),
                null,
                null);

        IncidentInput checkout = IncidentFixtures.examples().get(0);
        IncidentInput billing = IncidentFixtures.examples().get(1);

        double checkoutScore = vectorizer.cosine(vectorizer.vectorForSearch(query), vectorizer.vectorForSearch(checkout));
        double billingScore = vectorizer.cosine(vectorizer.vectorForSearch(query), vectorizer.vectorForSearch(billing));

        assertTrue(checkoutScore &gt; 0.75, "Expected close checkout match but got " + checkoutScore);
        assertTrue(checkoutScore &gt; billingScore + 0.35,
                "Expected checkout score " + checkoutScore + " to beat billing score " + billingScore);
    }

    @Test
    void vectorsUseTheConfiguredCollectionDimension() {
        float[] vector = vectorizer.vectorForSearch(IncidentFixtures.examples().get(0));

        assertEquals(384, vector.length);
    }
}</code></code></pre><p>The resource test starts Quarkus and Qdrant Dev Services:</p><pre><code><code>package dev.mainthread.incidents;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
class IncidentResourceTest {

    @Test
    void findsSimilarResolvedCheckoutIncident() {
        given()
                .when()
                .post("/incidents/seed")
                .then()
                .statusCode(200)
                .body("indexed", equalTo(5));

        given()
                .contentType("application/json")
                .body("""
                        {
                          "incident": {
                            "service": "checkout-service",
                            "environment": "prod",
                            "exceptionType": "java.lang.NullPointerException",
                            "message": "Cannot invoke DiscountPolicy.percentage because policy is null while pricing cart",
                            "stackTrace": [
                              "dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:91)",
                              "dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)",
                              "dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"
                            ]
                          },
                          "limit": 3,
                          "minScore": 0.60,
                          "filterService": "checkout-service",
                          "filterEnvironment": "prod",
                          "onlyResolved": true
                        }
                        """)
                .when()
                .post("/incidents/search")
                .then()
                .statusCode(200)
                .body("count", greaterThan(0))
                .body("matches[0].id", equalTo("INC-1001"))
                .body("matches[0].service", equalTo("checkout-service"));
    }

    @Test
    void rejectsIncidentsWithoutStackFrames() {
        given()
                .contentType("application/json")
                .body("""
                        {
                          "service": "checkout-service",
                          "environment": "prod",
                          "exceptionType": "java.lang.NullPointerException",
                          "message": "policy is null",
                          "stackTrace": []
                        }
                        """)
                .when()
                .post("/incidents")
                .then()
                .statusCode(400);
    }
}</code></code></pre><p>Run the tests:</p><pre><code><code>./mvnw test</code></code></pre><p>Expected result:</p><pre><code><code>Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS</code></code></pre><p>These tests caught two real issues while building the sample: the empty seed POST needed looser content negotiation, and Qdrant point IDs needed UUIDs instead of <code>INC-1001</code>. That is the reason to keep integration tests in a demo. They find the boring edges before readers do.</p><h2><strong>Production Edges</strong></h2><p>This app is small, but it creates real production questions.</p><p><strong>Vector quality</strong> starts with the feature design. Our hash vectorizer is deterministic and cheap, but it only captures token overlap. If you need semantic similarity, swap the vectorizer for an embedding model and keep the <code>IncidentArchive</code> boundary. The rest of the application should not care where the numbers came from.</p><p><strong>Collection management</strong> should move out of request handling. The <code>ensureCollection()</code> method keeps the tutorial self-contained. Production systems should create collections, payload indexes, and distance settings through provisioning, migrations, or platform automation.</p><p><strong>Payload filters need indexes at scale.</strong> Qdrant can filter by payload fields directly, and the <a href="https://qdrant.tech/documentation/search/filtering/">Qdrant filtering guide</a> recommends payload indexes for fields you filter on often. For this example the dataset has five points. For real incident history, create indexes for fields like <code>service</code>, <code>environment</code>, and <code>resolved</code>.</p><p><strong>Scores are application policy.</strong> <code>minScore=0.68</code> is a starting point for this deterministic vectorizer and sample data. Change the vectorizer and you must re-check the threshold. I would treat it like any relevance setting: measure on real incident pairs before trusting it during an outage.</p><p><strong>Readiness should fail when Qdrant fails.</strong> An empty incident search is worse than a clear dependency failure. If your fallback is &#8220;show keyword-only results,&#8221; make that explicit and observable. Do not let a broken vector store look like no matching incidents exist.</p><h2><strong>Conclusion</strong></h2><p>We built a Quarkus service that stores Java incident reports as Qdrant vectors, searches by failure shape, and still keeps service and environment constraints explicit through payload filters. Just remember: vectors do not have to mean &#8220;AI app.&#8221; Sometimes they are just a practical index for data that is similar without being equal.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Quarkus Multitenancy Without Tenant Plumbing in Every Service]]></title><description><![CDATA[Use the Quarkiverse Multitenancy extension to resolve tenants from headers, JWTs, or custom sources and keep your service layer free of tenant plumbing.]]></description><link>https://www.the-main-thread.com/p/quarkus-multi-tenant-extension</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-multi-tenant-extension</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Sun, 05 Jul 2026 06:09:08 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/afb6fdc9-b992-4378-b7f1-5af22340bcc0_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Tenant plumbing leaking through every service method gets ugly fast. <code>createInvoice(tenantId, ...)</code>, <code>loadDashboard(tenantId, ...)</code>, <code>cacheKey(tenantId, ...)</code> all look harmless for a while. Then the service layer turns into a mix of domain logic and reminders that isolation is still your job.</p><p><code>ThreadLocal</code> is the usual shortcut. It works until the request flow changes. A filter runs too late, a background task forgets cleanup, or a refactor crosses a boundary where the tenant should have been explicit.</p><p>Quarkus already covers two big pieces. <a href="https://quarkus.io/guides/security-openid-connect-multitenancy">OIDC multitenancy</a> picks the right authentication tenant. <a href="https://quarkus.io/guides/hibernate-orm#multitenancy">Hibernate ORM multitenancy</a> isolates data. The missing part is the boring middle: resolve one tenant id from the request, keep it in request state, and let the rest of the app stop caring.</p><p>The preview <a href="https://github.com/quarkiverse/quarkus-multitenancy">Quarkus Multitenancy extension</a> fills that gap. It gives you a small resolution API, a request-scoped <code>TenantContext</code>, HTTP resolvers for headers, JWT claims, cookies, and path segments, plus an ORM bridge that lets Hibernate route by the tenant already in that context.</p><p>We build a small fictional SaaS service called <strong>VaultBoard</strong>. It has two tenants, <code>acme</code> and <code>globex</code>, each backed by its own PostgreSQL database. We expose one REST API, create records for both tenants through the same endpoint, and prove that each tenant only sees its own data. After that, we switch the same app to JWT resolution and then add a custom host-based resolver.</p><h2><strong>What we build</strong></h2><ul><li><p>A Quarkus REST API with <code>GET /api/dashboards</code>, <code>POST /api/dashboards</code>, and <code>GET /api/dashboards/tenant</code></p></li><li><p>Two tenant databases, <code>acme</code> and <code>globex</code>, started automatically by Quarkus Dev Services</p></li><li><p>Header-based tenant resolution with <code>X-Tenant</code></p></li><li><p>Tenant-aware Hibernate ORM routing with no <code>tenantId</code> parameter in the service layer</p></li><li><p>A JWT-based tenant slice verified with a dedicated test profile</p></li><li><p>A custom host-based resolver verified with a dedicated test profile</p></li></ul><h2><strong>What you need</strong></h2><p>You need a normal Java and Quarkus setup plus a working container runtime. The version set here is <strong>Java 25</strong>, <strong>Quarkus 3.37.0</strong>, and <strong>Quarkus Multitenancy 0.1.0</strong>.</p><ul><li><p>JDK 25</p></li><li><p><a href="https://quarkus.io/guides/cli-tooling">Quarkus CLI</a></p></li><li><p>Docker or Podman</p></li><li><p><code>curl</code></p></li><li><p>About 45 minutes</p></li></ul><h2><strong>Create the project</strong></h2><p>Use the CLI to create the application without starter code and follow along or <a href="https://github.com/myfear/the-main-thread/tree/main/quarkus-multi-tenant-extension">grab the code from my Github repository</a>:</p><pre><code><code>quarkus create app io.mainthread:vaultboard \
  --package-name=io.mainthread.vaultboard \
  --extension='rest-jackson,hibernate-orm-panache,jdbc-postgresql' \
  --java=25 \
  --no-code</code></code></pre><p>The Quarkus CLI keeps the platform-managed dependencies aligned. The multitenancy extension is outside the platform, so add it and the Flyway extension manually to <code>pom.xml</code>:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.quarkus&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-flyway&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;io.quarkiverse.multitenancy&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-multitenancy-http&lt;/artifactId&gt;
    &lt;version&gt;0.1.0&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;io.quarkiverse.multitenancy&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-multitenancy-orm&lt;/artifactId&gt;
    &lt;version&gt;0.1.0&lt;/version&gt;
&lt;/dependency&gt;</code></code></pre><p>The two multitenancy artifacts handle different concerns:</p><ul><li><p><code>quarkus-multitenancy-http</code> resolves the tenant from the incoming request and stores it in <code>TenantContext</code></p></li><li><p><code>quarkus-multitenancy-orm</code> bridges that resolved tenant into Hibernate ORM</p></li></ul><p>One detail is easy to miss if you saw older examples of this extension. The coordinates are now <code>io.quarkiverse.multitenancy:*</code>, not the earlier pre-Quarkiverse group. That is one good reason to pin the preview version explicitly.</p><h2><strong>Let Dev Services own the databases</strong></h2><p>As soon as the named datasources exist and <code>jdbc-postgresql</code> is on the classpath, Quarkus Dev Services starts the PostgreSQL containers for us. No Compose file. No JDBC URLs to configure by hand.</p><p>Put this in <code>src/main/resources/application.properties</code>:</p><pre><code><code>quarkus.hibernate-orm.datasource=__bootstrap
quarkus.hibernate-orm.schema-management.strategy=none
quarkus.hibernate-orm.multitenant=DATABASE
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.validate-in-dev-mode=false

quarkus.datasource.devservices.enabled=false
quarkus.datasource.__bootstrap.db-kind=postgresql
quarkus.datasource.__bootstrap.devservices.db-name=bootstrap

quarkus.datasource.acme.db-kind=postgresql
quarkus.datasource.acme.devservices.db-name=acme

quarkus.datasource.globex.db-kind=postgresql
quarkus.datasource.globex.devservices.db-name=globex

quarkus.flyway.acme.locations=classpath:db/migration
quarkus.flyway.acme.migrate-at-start=true
quarkus.flyway.globex.locations=classpath:db/migration
quarkus.flyway.globex.migrate-at-start=true

quarkus.multi-tenant.http.enabled=true
quarkus.multi-tenant.http.strategy=header
quarkus.multi-tenant.http.header-name=X-Tenant</code></code></pre><p><code>quarkus.datasource.devservices.enabled=false</code> disables Dev Services only for the unused default datasource. The named datasources still start their own PostgreSQL containers. I set this so Quarkus does not invent a default datasource we do not want.</p><p><code>quarkus.datasource.__bootstrap.*</code> defines the bootstrap datasource. The ORM bridge uses it before a request-scoped tenant exists. If you skip it, Hibernate has no safe datasource to use during startup.</p><p><code>quarkus.multi-tenant.http.strategy=header</code> keeps this slice in header mode. The preview extension still defaults to <code>header,jwt,cookie</code>. If you leave the property unset, <code>jwt</code> stays in the chain, and the app starts asking for JWT verification settings even though this demo is still on headers.</p><p><code>quarkus.flyway.&lt;tenant&gt;.migrate-at-start=true</code> runs Flyway migrations on each tenant datasource when the app boots. Both tenants point at the same <code>classpath:db/migration</code> location because they share the same table structure. Hibernate&#8217;s own schema management does not support DATABASE multitenancy, so Flyway handles it instead.</p><h2><strong>Model the data</strong></h2><p>Create the entity first:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import java.math.BigDecimal;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

@Entity
@Table(name = "dashboards")
public class Dashboard extends PanacheEntity {

    @Column(nullable = false)
    public String name;

    @Column(name = "owner_email", nullable = false)
    public String ownerEmail;

    @Column(name = "monthly_budget", nullable = false, precision = 12, scale = 2)
    public BigDecimal monthlyBudget;
}</code></code></pre><p>There is no tenant field on the row model. Each tenant gets its own datasource, so the entity stays clean.</p><p>Create the request type next:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import java.math.BigDecimal;

public record CreateDashboardRequest(
        String name,
        String ownerEmail,
        BigDecimal monthlyBudget) {
}</code></code></pre><h2><strong>Keep the service layer clean</strong></h2><p><code>DashboardService</code> is small on purpose:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import java.util.List;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;

@ApplicationScoped
public class DashboardService {

    public List&lt;Dashboard&gt; listAll() {
        return Dashboard.listAll();
    }

    @Transactional
    public Dashboard create(CreateDashboardRequest request) {
        Dashboard dashboard = new Dashboard();
        dashboard.name = request.name();
        dashboard.ownerEmail = request.ownerEmail();
        dashboard.monthlyBudget = request.monthlyBudget();
        dashboard.persist();
        return dashboard;
    }
}</code></code></pre><p>The service never asks which tenant is active. No <code>tenantId</code> parameter. No context lookup. Hibernate already knows which datasource to use.</p><p>The resource follows the same pattern:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import java.util.List;
import java.util.Map;

import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/api/dashboards")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class DashboardResource {

    private final DashboardService dashboardService;
    private final TenantContext tenantContext;

    public DashboardResource(DashboardService dashboardService, TenantContext tenantContext) {
        this.dashboardService = dashboardService;
        this.tenantContext = tenantContext;
    }

    @GET
    public List&lt;Dashboard&gt; list() {
        return dashboardService.listAll();
    }

    @POST
    public Dashboard create(CreateDashboardRequest request) {
        return dashboardService.create(request);
    }

    @GET
    @Path("/tenant")
    public Map&lt;String, String&gt; currentTenant() {
        return Map.of("tenant", tenantContext.getTenantId().orElse("missing"));
    }
}</code></code></pre><p><code>/tenant</code> is a cheap probe. It shows what the resolver stored before we involve the database.</p><h2><strong>Migrate the tenant schemas with Flyway</strong></h2><p>Hibernate&#8217;s built-in schema management (<code>drop-and-create</code>, <code>update</code>) does not run against tenant datasources in DATABASE mode. It only targets the single datasource Hibernate is pointed at, which is <code>__bootstrap</code> here. The Quarkus documentation recommends Flyway per named datasource instead.</p><p>Both tenants share the same table structure, so one migration file is enough. Create <code>src/main/resources/db/migration/V1__create_dashboards.sql</code>:</p><pre><code><code>CREATE SEQUENCE IF NOT EXISTS dashboards_SEQ START WITH 1 INCREMENT BY 50;

CREATE TABLE IF NOT EXISTS dashboards (
    monthly_budget NUMERIC(12,2) NOT NULL,
    id BIGINT NOT NULL,
    name VARCHAR(255) NOT NULL,
    owner_email VARCHAR(255) NOT NULL,
    PRIMARY KEY (id)
);</code></code></pre><p>The Flyway properties we added earlier point both <code>acme</code> and <code>globex</code> at this same <code>classpath:db/migration</code> location and run the migration on startup. No Java code needed.</p><h2><strong>Run the header-based slice</strong></h2><p>The first worked slice should prove the full path: resolve the tenant from a header, store it in <code>TenantContext</code>, route Hibernate to the right datasource, and keep each tenant&#8217;s rows separate.</p><p>Start the app in one terminal:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>On the first boot, Dev Services starts three PostgreSQL containers: <code>__bootstrap</code>, <code>acme</code>, and <code>globex</code>. Keep that terminal running. Open a second terminal in the same project directory for the requests below.</p><ol><li><p>Confirm that the resolver sees the tenant header and that the database starts empty.</p></li></ol><pre><code><code>curl -H "X-Tenant: acme" http://localhost:8080/api/dashboards/tenant
curl -H "X-Tenant: globex" http://localhost:8080/api/dashboards/tenant
curl -H "X-Tenant: acme" http://localhost:8080/api/dashboards</code></code></pre><p>Expected output:</p><pre><code><code>{"tenant":"acme"}
{"tenant":"globex"}
[]</code></code></pre><p><code>/tenant</code> proves the HTTP resolver is working. The empty list matters too. It tells you there is no stale data hiding in the tenant database before the real demo starts.</p><ol start="2"><li><p>Create one dashboard for <code>acme</code>.</p></li></ol><pre><code><code>curl -X POST \
  -H "X-Tenant: acme" \
  -H "Content-Type: application/json" \
  -d '{"name":"ARR","ownerEmail":"alice@acme.example","monthlyBudget":120000.00}' \
  http://localhost:8080/api/dashboards</code></code></pre><p>Expected output:</p><pre><code><code>{"id":1,"name":"ARR","ownerEmail":"alice@acme.example","monthlyBudget":120000.00}</code></code></pre><ol start="3"><li><p>Create one dashboard for <code>globex</code>.</p></li></ol><pre><code><code>curl -X POST \
  -H "X-Tenant: globex" \
  -H "Content-Type: application/json" \
  -d '{"name":"Cash Flow","ownerEmail":"finops@globex.example","monthlyBudget":98000.00}' \
  http://localhost:8080/api/dashboards</code></code></pre><p>Expected output:</p><pre><code><code>{"id":1,"name":"Cash Flow","ownerEmail":"finops@globex.example","monthlyBudget":98000.00}</code></code></pre><p>Before the next step, predict what each tenant should see. The right answer is one row per tenant, not a shared list with two rows.</p><ol start="4"><li><p>Read the data back through the same endpoint.</p></li></ol><pre><code><code>curl -H "X-Tenant: acme" http://localhost:8080/api/dashboards
curl -H "X-Tenant: globex" http://localhost:8080/api/dashboards</code></code></pre><p>Expected output on a fresh start:</p><pre><code><code>[{"id":1,"name":"ARR","ownerEmail":"alice@acme.example","monthlyBudget":120000.00}]
[{"id":1,"name":"Cash Flow","ownerEmail":"finops@globex.example","monthlyBudget":98000.00}]</code></code></pre><p>Both rows start at <code>id=1</code>. That is normal because each tenant has its own database and its own sequence. If one response shows both rows, tenant resolution or datasource routing is wrong.</p><ol start="5"><li><p>Check the failure path for header mode.</p></li></ol><pre><code><code>curl -i http://localhost:8080/api/dashboards</code></code></pre><p>Expected output starts with:</p><pre><code><code>HTTP/1.1 400 Bad Request</code></code></pre><p>That error tells you the header-based resolver chain is active. In this mode, the request must include <code>X-Tenant</code>.</p><h2><strong>Add a repeatable header-mode test</strong></h2><p>I want the first slice under test before I add more resolution modes.</p><p>Dev Services keeps the tenant containers alive for the whole test run, so I add one helper that clears both tenant databases before and after each test. Outside HTTP there is no incoming request to populate <code>TenantContext</code>, so the helper sets the tenant directly:</p><pre><code><code>package io.mainthread.vaultboard.support;

import java.util.List;

import io.mainthread.vaultboard.dashboard.Dashboard;
import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;

@ApplicationScoped
public class TenantDataCleaner {

    private final TenantContext tenantContext;

    public TenantDataCleaner(TenantContext tenantContext) {
        this.tenantContext = tenantContext;
    }

    @Transactional
    public void clearAll() {
        for (String tenant : List.of("acme", "globex")) {
            tenantContext.setTenantId(tenant);
            Dashboard.deleteAll();
        }
        tenantContext.clear();
    }
}</code></code></pre><p>Then add the test class:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

import java.math.BigDecimal;

import io.mainthread.vaultboard.support.TenantDataCleaner;
import jakarta.inject.Inject;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
class DashboardResourceTest {

    @Inject
    TenantDataCleaner tenantDataCleaner;

    @BeforeEach
    void resetBeforeEachTest() {
        tenantDataCleaner.clearAll();
    }

    @AfterEach
    void cleanup() {
        tenantDataCleaner.clearAll();
    }

    @Test
    void acmeDoesNotSeeGlobexData() {
        given()
                .header("X-Tenant", "acme")
                .contentType("application/json")
                .body(new CreateDashboardRequest("ARR", "alice@acme.example", new BigDecimal("120000.00")))
                .when().post("/api/dashboards")
                .then()
                .statusCode(200);

        given()
                .header("X-Tenant", "globex")
                .contentType("application/json")
                .body(new CreateDashboardRequest("Cash Flow", "finops@globex.example", new BigDecimal("98000.00")))
                .when().post("/api/dashboards")
                .then()
                .statusCode(200);

        given()
                .header("X-Tenant", "acme")
                .when().get("/api/dashboards")
                .then()
                .statusCode(200)
                .body("$", hasSize(1))
                .body("[0].ownerEmail", equalTo("alice@acme.example"));

        given()
                .header("X-Tenant", "globex")
                .when().get("/api/dashboards")
                .then()
                .statusCode(200)
                .body("$", hasSize(1))
                .body("[0].ownerEmail", equalTo("finops@globex.example"));
    }

    @Test
    void missingHeaderFailsFastInHeaderMode() {
        given()
                .when().get("/api/dashboards")
                .then()
                .statusCode(400);
    }
}</code></code></pre><p>That second test matters. In header mode, the ORM module adds a filter that rejects requests without <code>X-Tenant</code>.</p><h2><strong>Why the resolution API matters</strong></h2><p>The contract matters more than any single resolver. The built-in header resolver is one implementation. <code>TenantResolver</code> no longer returns <code>Optional&lt;String&gt;</code>. It returns a sealed <code>TenantResolution</code> with three outcomes:</p><ul><li><p><code>Resolved</code> means a resolver found a usable tenant id, so the chain stops</p></li><li><p><code>NotApplicable</code> means this resolver had nothing to work with, so the next strategy may try</p></li><li><p><code>Rejected</code> means input was present but invalid, so the request must stop with HTTP 401</p></li></ul><p>A missing header and a bad JWT are different cases. Missing input may allow fallback. Present but invalid input should stop the request. The extension makes that rule explicit in the API.</p><h2><strong>Add JWT resolution without rewriting the main config</strong></h2><p>I leave the application itself in header mode. For the JWT slice, I switch strategies only inside a test profile. That keeps the first slice stable while we add another one.</p><p>Add the JWT dependencies:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.quarkus&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-smallrye-jwt&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;io.quarkus&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-smallrye-jwt-build&lt;/artifactId&gt;
&lt;/dependency&gt;</code></code></pre><p>Generate a dev-only key pair and place both files under <code>src/main/resources</code>:</p><pre><code><code>openssl genrsa -out src/main/resources/privateKey.pem 2048
openssl rsa -pubout -in src/main/resources/privateKey.pem -out src/main/resources/publicKey.pem</code></code></pre><p>Now add the profile:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import java.util.Map;

import io.quarkus.test.junit.QuarkusTestProfile;

public class JwtTenantTestProfile implements QuarkusTestProfile {

    @Override
    public Map&lt;String, String&gt; getConfigOverrides() {
        return Map.of(
                "quarkus.multi-tenant.http.strategy", "jwt",
                "quarkus.multi-tenant.orm.header-filter.enabled", "false",
                "mp.jwt.verify.publickey.location", "publicKey.pem",
                "mp.jwt.verify.publickey.algorithm", "RS256",
                "mp.jwt.verify.issuer", "https://auth.vaultboard.example",
                "smallrye.jwt.sign.key.location", "privateKey.pem");
    }
}</code></code></pre><p><code>quarkus.multi-tenant.orm.header-filter.enabled=false</code> disables the ORM-side header filter. That filter is right for header mode, but in JWT mode it keeps asking for <code>X-Tenant</code>.</p><p>Then add the test:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;

import java.time.Duration;

import io.smallrye.jwt.build.Jwt;
import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;

@QuarkusTest
@TestProfile(JwtTenantTestProfile.class)
class JwtTenantResolutionTest {

    @Test
    void resolvesTenantFromVerifiedJwtClaim() {
        String token = Jwt.upn("alice")
                .issuer("https://auth.vaultboard.example")
                .claim("tenant", "acme")
                .expiresIn(Duration.ofMinutes(15))
                .sign();

        given()
                .header("Authorization", "Bearer " + token)
                .when().get("/api/dashboards/tenant")
                .then()
                .statusCode(200)
                .body("tenant", equalTo("acme"));
    }

    @Test
    void missingTenantClaimRejectsRequest() {
        String token = Jwt.upn("alice")
                .issuer("https://auth.vaultboard.example")
                .expiresIn(Duration.ofMinutes(15))
                .sign();

        given()
                .header("Authorization", "Bearer " + token)
                .when().get("/api/dashboards/tenant")
                .then()
                .statusCode(401);
    }
}</code></code></pre><p>This is the behavior I want. A verified token with a <code>tenant</code> claim resolves the tenant. A verified token without that claim gets a <code>401</code>.</p><h2><strong>Add a host-based resolver</strong></h2><p>JWT is one input source. The custom resolver API lets you add your own.</p><p>This resolver reads the tenant from the subdomain and rejects unknown values:</p><pre><code><code>package io.mainthread.vaultboard.tenant;

import java.util.Locale;
import java.util.Optional;
import java.util.Set;

import io.quarkiverse.multitenancy.core.runtime.api.TenantResolution;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolutionContext;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolver;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.container.ContainerRequestContext;

@ApplicationScoped
public class HostTenantResolver implements TenantResolver {

    private static final Set&lt;String&gt; KNOWN_TENANTS = Set.of("acme", "globex");

    @Override
    public TenantResolution resolve(TenantResolutionContext context) {
        Optional&lt;ContainerRequestContext&gt; request = context.get(ContainerRequestContext.class);
        if (request.isEmpty()) {
            return TenantResolution.notApplicable();
        }

        String hostHeader = request.get().getHeaderString("Host");
        if (hostHeader == null || hostHeader.isBlank()) {
            return TenantResolution.notApplicable();
        }

        String authority = hostHeader.trim().toLowerCase(Locale.ROOT);
        String host = authority.split(":", 2)[0];
        if (host.equals("localhost") || host.equals("127.0.0.1")) {
            return TenantResolution.notApplicable();
        }

        int firstDot = host.indexOf('.');
        if (firstDot &lt; 1) {
            return TenantResolution.notApplicable();
        }

        String tenant = host.substring(0, firstDot);
        if (!KNOWN_TENANTS.contains(tenant)) {
            return TenantResolution.rejected("Unknown tenant host: " + tenant);
        }

        return TenantResolution.resolved(tenant);
    }
}</code></code></pre><p>The <code>localhost</code> branch matters. Without it, normal local traffic starts behaving like a broken multitenant DNS setup.</p><p>Custom resolvers run before the built-in strategy chain, so the host slice only needs one test profile change:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import java.util.Map;

import io.quarkus.test.junit.QuarkusTestProfile;

public class HostTenantTestProfile implements QuarkusTestProfile {

    @Override
    public Map&lt;String, String&gt; getConfigOverrides() {
        return Map.of(
                "quarkus.multi-tenant.orm.header-filter.enabled", "false");
    }
}</code></code></pre><p>The test stays small:</p><pre><code><code>package io.mainthread.vaultboard.dashboard;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;

@QuarkusTest
@TestProfile(HostTenantTestProfile.class)
class HostTenantResolutionTest {

    @Test
    void resolvesTenantFromHostHeader() {
        given()
                .header("Host", "acme.vaultboard.example")
                .when().get("/api/dashboards/tenant")
                .then()
                .statusCode(200)
                .body("tenant", equalTo("acme"));
    }

    @Test
    void rejectsUnknownTenantHost() {
        given()
                .header("Host", "unknown.vaultboard.example")
                .when().get("/api/dashboards/tenant")
                .then()
                .statusCode(401);
    }
}</code></code></pre><p>At this point the same <code>TenantContext</code> supports three inputs: header, JWT, and a custom host resolver.</p><h2><strong>What I would change for production</strong></h2><p>Before I would call this production-ready, I would change three things:</p><ul><li><p>Map external tenant ids to an allowed datasource map instead of trusting raw headers, claims, or hostnames directly</p></li><li><p>Keep <code>quarkus.multi-tenant.http.strategy</code> explicit on every entry point instead of relying on the default chain</p></li><li><p>Leave the ORM header filter enabled only for header mode, and disable it for JWT, host, path, or cookie-driven flows</p></li></ul><h2><strong>Prove it</strong></h2><p>I run the slices one by one while building:</p><pre><code><code>./mvnw test -Dtest=DashboardResourceTest
./mvnw test -Dtest=JwtTenantResolutionTest
./mvnw test -Dtest=HostTenantResolutionTest</code></code></pre><p>Each test covers one resolution mode:</p><ul><li><p>Header mode proves tenant-aware CRUD and the ORM-side header filter</p></li><li><p>JWT mode proves claim-based tenant resolution and request rejection</p></li><li><p>Host mode proves a custom resolver can participate without rewriting the rest of the app</p></li></ul><h2><strong>Result</strong></h2><p>This is the shape I want for multi-tenant code. Resolve the tenant once, store it in <code>TenantContext</code>, and let domain code talk about dashboards, invoices, and users. Tenant routing stays at the edge unless there is a real reason to pull it inward.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Deterministic Islands in Probabilistic Workflows]]></title><description><![CDATA[Reliable agent systems do not make the LLM deterministic; they put model judgment around parsers, schemas, tests, CI gates, security scans, and approval points.]]></description><link>https://www.the-main-thread.com/p/deterministic-islands-probabilistic-workflows</link><guid isPermaLink="false">https://www.the-main-thread.com/p/deterministic-islands-probabilistic-workflows</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Sat, 04 Jul 2026 06:08:31 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ee8d7a96-2071-4d2e-b059-f9e5f281a2ce_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first time you wire an agent workflow with hard checks, it can feel less autonomous. The model reads a messy request, guesses what the user means, writes code, edits prose, calls tools, and explains errors. Then, right in the middle of all that flexible reasoning, the workflow stops and asks a much simpler question:</p><p>Did the parser read the file?</p><p>Did the schema validate?</p><p>Did the tests pass?</p><p>Did the rendered artifact contain the expected objects?</p><p>Did the human approve the decision?</p><p>That pause turns a model claim into something the workflow can inspect. Reliable agent systems do not try to make the language model deterministic. They put the language model around deterministic islands: scripts, schemas, parsers, type systems, linters, tests, CI gates, and approval points. The model can reason, draft, route, and repair. The islands own the facts.</p><p>A large language model is good at working with incomplete context. It can infer intent from a vague issue, connect a failing test to the likely source file, or translate a human comment into a patch plan. That flexibility is why we use it. It can also talk itself into a clean answer while the change is still wrong, so it should not be the final judge of correctness.</p><h2><strong>Better prompts help, but they do not create contracts</strong></h2><p>A better prompt can make the model more consistent. It can name the goal, define style, list allowed tools, and remind the agent to run checks before it claims success. All of that helps.</p><p>But a prompt is still an instruction to a probabilistic system. It changes the distribution of likely answers. It does not create a hard guarantee.</p><p>If the model says &#8220;I validated the deck,&#8221; that sentence is cheap. If a script opened the generated PPTX, inspected the slide XML, rendered each slide to PNG, counted unresolved placeholders, checked overlap warnings, and returned a structured report, that is a different thing. The first one is a claim. The second one is evidence.</p><p>This is the mistake I see in many early agent setups. The team keeps adding instructions:</p><ul><li><p>be careful</p></li><li><p>verify the result</p></li><li><p>do not hallucinate</p></li><li><p>make sure the output is valid</p></li><li><p>double-check before responding</p></li></ul><p>Those lines help a little. They can reduce sloppy behavior. They still leave the core problem open: no definition of &#8220;valid,&#8221; no validator run, and no gate that blocks a bad artifact from moving forward.</p><p>Reliability needs something outside the model to say yes or no.</p><h2><strong>What a deterministic island is</strong></h2><p>A deterministic island is a step in the workflow where truth is owned by something more constrained than the language model.</p><p>This is an engineering contract, not mathematics. A test can be flaky. A linter can have a bug. A parser can change behavior between versions. But the step still has a contract: same input, same environment, same version, same result. When it fails, it should fail in a way another program can read.</p><p>Examples:</p><ul><li><p>A JSON parser either parses the file or reports a line and column.</p></li><li><p>A schema validator accepts or rejects a payload against explicit fields and types.</p></li><li><p>A type checker says whether the code satisfies the language rules.</p></li><li><p>A unit test checks a behavior and returns pass or fail.</p></li><li><p>A linter catches formatting, unsafe patterns, or broken references.</p></li><li><p>A renderer exports an artifact and exposes layout warnings.</p></li><li><p>A CI gate runs the checks in a clean environment.</p></li><li><p>A human approval step decides whether the change should ship.</p></li></ul><p>A human approval point can be an island too, as long as the workflow is clear about what the human owns. Humans are good at deciding intent, taste, risk, policy, and business meaning. They are not the right checksum algorithm for 27 generated files.</p><p>The model moves between these islands. It reads the evidence from one step, decides what to try next, and hands the next concrete task to another constrained system.</p><h2><strong>The simple loop</strong></h2><p>The loop is simple:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!OB8F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!OB8F!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 424w, https://substackcdn.com/image/fetch/$s_!OB8F!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 848w, https://substackcdn.com/image/fetch/$s_!OB8F!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 1272w, https://substackcdn.com/image/fetch/$s_!OB8F!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!OB8F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png" width="303" height="717.3275563258233" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/76513907-79ea-4154-adea-7d6aee70248a_577x1366.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1366,&quot;width&quot;:577,&quot;resizeWidth&quot;:303,&quot;bytes&quot;:50654,&quot;alt&quot;:&quot;Probabilistic Loop&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/204078227?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Probabilistic Loop" title="Probabilistic Loop" srcset="https://substackcdn.com/image/fetch/$s_!OB8F!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 424w, https://substackcdn.com/image/fetch/$s_!OB8F!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 848w, https://substackcdn.com/image/fetch/$s_!OB8F!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 1272w, https://substackcdn.com/image/fetch/$s_!OB8F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F76513907-79ea-4154-adea-7d6aee70248a_577x1366.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The model sits between checkpoints. It handles ambiguity, chooses a path, and converts failure output into the next attempt. Every factual claim goes through a narrower system before the workflow treats it as true.</p><h2><strong>A concrete example: generated presentation artifacts</strong></h2><p>Presentation generation is a good stress test because it mixes content, layout, binary files, brand rules, and visual judgment. It is also easy to fake success. A model can say &#8220;the slides look clean&#8221; without ever opening the final deck. That is not validation.</p><p>A stronger workflow starts by inspecting the source deck with a real parser. It reads every slide, extracts text boxes, images, tables, placeholders, fonts, dimensions, and object identifiers. The model can reason over that inventory, but it should not invent the inventory.</p><p>Then the workflow creates a structured map:</p><ul><li><p>output slide 1 reuses source slide 3</p></li><li><p>this text box is rewritten</p></li><li><p>this table is filled</p></li><li><p>this placeholder is deleted</p></li><li><p>this logo stays untouched</p></li><li><p>no new objects are allowed on this slide</p></li></ul><p>That map is a deterministic island if it is schema-validated. Required fields are present. Slide numbers are sequential. Source slide references exist. Edit targets resolve to real objects. If the map says to edit <code>shape-17</code> and there is no <code>shape-17</code>, the workflow stops early.</p><p>After that, a generator duplicates the selected source slides and edits inherited objects in place. Again, the model can choose the content and explain why a source slide fits. The script owns the actual mutation.</p><p>The final check renders every slide and inspects the exported file. It looks for unresolved placeholders, broken page markers, missing required assets, text clipping, overlap warnings, and layout drift. A good check does not say &#8220;looks good.&#8221; It says something like:</p><pre><code><code>{
  "status": "fail",
  "checks": {
    "renderedSlides": 12,
    "unresolvedPlaceholders": 2,
    "overlapWarnings": 0,
    "missingRequiredAssets": 0
  },
  "issues": [
    {
      "code": "unresolved-placeholder",
      "slide": 7,
      "objectId": "title-placeholder",
      "message": "Inherited title placeholder is empty."
    }
  ]
}</code></code></pre><p>Now the agent has something it can use. It can go back to slide 7, locate the inherited title placeholder, and fix the real object. It does not have to stare at a screenshot and guess where the presentation format might hide a structural placeholder. That detail belongs to the checker.</p><p>The same pattern works outside slides.</p><h2><strong>Parsing files: let parsers own structure</strong></h2><p>Agents are good at reading text. They are much less reliable when you ask them to infer structure that a parser can provide exactly.</p><p>If the input is JSON, parse JSON. If it is XML, use an XML parser. If it is Java, use the compiler, an AST parser, or the IDE refactoring engine when available. If it is a zip-based document format, inspect the archive and the internal manifests. If it is Markdown with front matter, parse the front matter as data before editing prose.</p><p>The model can decide that a publishing pack probably contains social copy, images, tags, and scheduling notes. A parser should say which fields exist and where they live. The model can suggest that a Java class needs a rename. The refactoring tool should find symbol references. The model can guess that a deck has a title placeholder. The presentation inspector should return the placeholder ID.</p><p>This is beginner-friendly in practice because it removes mystery. The agent is no longer &#8220;looking at the project.&#8221; It is calling a tool that returns named facts.</p><h2><strong>Schemas and typed outputs make the next step safer</strong></h2><p>Structured output is one of the simplest ways to make agent workflows less fragile.</p><p>Free text makes the next step interpret intent again:</p><pre><code><code>I think slide 3 should become the opening slide and we should update the headline.</code></code></pre><p>A typed shape gives the workflow something to validate:</p><pre><code><code>{
  "outputSlide": 1,
  "sourceSlide": 3,
  "role": "opening",
  "edits": [
    {
      "targetId": "shape-title",
      "action": "rewrite",
      "text": "Quarterly rollout plan"
    }
  ]
}</code></code></pre><p>The schema can require <code>outputSlide</code>, <code>sourceSlide</code>, <code>role</code>, and <code>edits</code>. It can restrict <code>action</code> to known values. It can reject an edit without a target. It can reject a slide number that does not exist.</p><p>A schema cannot know whether &#8220;Quarterly rollout plan&#8221; is the right headline. It can still keep the workflow out of impossible states. That removes a large class of failures before the model gets another chance to improvise.</p><p>Typed outputs also make review easier. Instead of reading a long agent explanation and trying to infer what will happen, the human can inspect a concise plan:</p><ul><li><p>which files change</p></li><li><p>which artifacts are generated</p></li><li><p>which channels are targeted</p></li><li><p>which checks will run</p></li><li><p>which decision needs approval</p></li></ul><p>The model can still write the explanation. The machine path gets data.</p><h2><strong>Good tool responses for agents</strong></h2><p>Tools built for humans often return a nice sentence and a wall of logs. Tools built for agents need a different shape.</p><p>A good response should include:</p><ul><li><p><code>status</code>: <code>success</code>, <code>fail</code>, or <code>blocked</code></p></li><li><p>stable error codes plus a short prose message</p></li><li><p>exact file paths, line numbers, object IDs, slide numbers, test names, or rule IDs</p></li><li><p>counts that describe the scope of the check</p></li><li><p>the command, version, or rule set used</p></li><li><p>a short human summary</p></li><li><p>structured issue records the model can act on</p></li><li><p>retry guidance when the failure is environmental</p></li><li><p>a path to full logs or raw output when the payload is too large</p></li></ul><p>For example, a security scan needs the rule ID, file, line, matched snippet, severity, and remediation hint. A publishing tool needs the channel ID, post ID, scheduled timestamp, timezone, and preview URL if one exists. A test runner should separate compilation failure, test failure, timeout, and infrastructure failure. Those are different next steps.</p><p>Vague errors make agents guess. Guessing is exactly what the deterministic island was supposed to reduce.</p><h2><strong>Refactoring: let the model propose, let the tools verify</strong></h2><p>Refactoring shows the boundary well.</p><p>An agent can read a codebase and decide that <code>CustomerManager</code> should become <code>CustomerService</code>, that a method should move, or that repeated code should become a helper. That judgment combines naming, intent, local style, and trade-offs. The toolchain should handle the mechanical proof.</p><p>The mechanical change should lean on deterministic tools:</p><ul><li><p>symbol-aware rename when the language server supports it</p></li><li><p>AST-based transforms for repeated code shapes</p></li><li><p>compiler checks for type errors</p></li><li><p>unit tests for behavior</p></li><li><p>formatting and import sorting</p></li><li><p>dependency analysis when public APIs move</p></li></ul><p>The agent can write the first patch, run the checks, read failures, and repair. The compiler owns type truth. The tests own behavior truth inside their scope. The linter owns the style rules it knows. CI owns the clean-room replay.</p><p>This is also why &#8220;the agent said it reviewed the code&#8221; is a weak signal. A review without a diff, tests, static checks, and clear findings is just commentary.</p><h2><strong>Publishing: approval should be about intent</strong></h2><p>Publishing workflows are another good example because they combine mechanical details and human judgment.</p><p>An agent can draft social copy from an article. It can create variants for different channels. It can select a short hook, pull a canonical URL, and suggest a schedule. Those are good jobs for a model.</p><p>The mechanical checks should be deterministic:</p><ul><li><p>parse the article metadata</p></li><li><p>extract the canonical URL</p></li><li><p>verify the image path exists</p></li><li><p>inspect the Open Graph preview</p></li><li><p>look up channel IDs from the provider because prose names drift</p></li><li><p>check for duplicate scheduled posts</p></li><li><p>validate that scheduled times are in the future and in the right timezone</p></li><li><p>enforce character limits</p></li><li><p>return the exact post IDs after scheduling</p></li></ul><p>The human should approve the message, channel choice, timing, and risk. The human should not have to manually re-check whether the Mastodon post exceeded a character limit, whether the LinkedIn channel ID was copied correctly, or whether the URL resolves.</p><p>That is the point of the workflow. Human attention is expensive. Spend it on decisions.</p><h2><strong>Tests, linters, hooks, and CI turn guesses into evidence</strong></h2><p>The model can say &#8220;this should fix it.&#8221; Tests decide whether the behavior changed the way you expected.</p><p>Local checks catch fast mistakes. A linter catches a broken link, a missing field, a forbidden API, or an unsafe pattern before review. A pre-commit hook catches problems before they leave the workstation. CI runs the same checks in a clean environment, which catches missing files, hidden local state, version drift, and the usual &#8220;works on my machine&#8221; problem.</p><p>None of these checks prove the system is correct. They prove narrower claims:</p><ul><li><p>this file parses</p></li><li><p>this generated artifact can be opened</p></li><li><p>this schema is valid</p></li><li><p>this code compiles</p></li><li><p>this test still passes</p></li><li><p>this forbidden pattern is absent</p></li><li><p>this package has no known critical vulnerability under the configured scanner</p></li></ul><p>That list is still worth a lot. Reliable systems are built from many narrow claims with clear owners.</p><h2><strong>Security checks need their own islands</strong></h2><p>Security is a bad place to trust a model&#8217;s confidence.</p><p>An agent can help explain a security finding, suggest a safer API, or update code to pass a rule. But the detection step should be deterministic where possible:</p><ul><li><p>secret scanners for tokens and credentials</p></li><li><p>static analysis for risky code patterns</p></li><li><p>dependency scanners for vulnerable packages</p></li><li><p>policy checks for container images or infrastructure changes</p></li><li><p>permission checks before tools touch external systems</p></li><li><p>approval gates for deploys, publishes, and destructive actions</p></li></ul><p>The reviewer still owns exceptions and policy calls. The scanner should still find the pattern every time.</p><h2><strong>Common failure modes</strong></h2><p>The pattern is simple. The failures are also simple.</p><p><strong>Fake validation</strong></p><p>The agent says it validated the result, but no validator ran. Or the validator only checked that a file exists. Or the check looked at the generated source but not the exported artifact. This is common with binary outputs, generated docs, and UI screenshots.</p><p>The fix is to validate the thing the user will receive.</p><p><strong>Hand-edited generated output</strong></p><p>Someone fixes the generated PPTX, PDF, OpenAPI file, or code output by hand after the generator runs. The artifact looks right once, but the workflow is now split. The next run overwrites the fix or produces a different result.</p><p>If a manual edit is necessary, make it an input, update the generator, or record it as an explicit patch step with a check after it.</p><p><strong>Skipped checks</strong></p><p>Small changes are where people skip checks because the change feels obvious. Agents learn the same bad habit if the workflow lets them. A one-line publishing change can post to the wrong channel. A tiny refactor can break serialization. A harmless deck edit can leave an invisible placeholder behind.</p><p>Make the cheap checks automatic. Save human exceptions for the expensive ones.</p><p><strong>Too many tools</strong></p><p>A giant tool menu can make an agent worse. It chooses the wrong tool, calls tools in the wrong order, or spends time exploring options that should not exist for the task.</p><p>Good workflows expose fewer tools with clearer contracts. A tool named <code>inspect_presentation</code> that returns slide objects is easier to use than a general &#8220;run any office command&#8221; tool with no clear contract.</p><p><strong>Vague errors</strong></p><p><code>Validation failed</code> is barely an error. The agent now has to infer the cause, and it may repair the wrong thing.</p><p>Errors should name the failed check, the target, the evidence, and the next action. <code>unresolved-placeholder on slide 7 object title-placeholder</code> is something an agent can fix.</p><h2><strong>The real pattern</strong></h2><p>The model is the reasoning layer. It handles ambiguity, proposes changes, explains failures, and decides what to try next. Deterministic islands are the truth layer. They parse, validate, generate, test, scan, and gate.</p><p>Good workflows move between the two deliberately.</p><p>Keep the model away from jobs we already have tools for: compiling, parsing, linting, scanning, rendering, and replaying checks in CI. The engineering task is to make those tools available at the right points, with outputs the model can read and humans can trust.</p><p>That is how agent systems become reliable enough for real projects. The probabilistic reasoning stays. It gets a map, a set of checkpoints, and a few hard places where guessing stops.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Quarkus SPDX SBOMs: JSON-LD, Licenses, Real Checks]]></title><description><![CDATA[Build a small Quarkus service, generate an SPDX 3.0.1 SBOM, inspect the JSON-LD graph, and see exactly where NTIA and CISA checks still fail.]]></description><link>https://www.the-main-thread.com/p/quarkus-spdx-sbom-json-ld-license-compliance</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-spdx-sbom-json-ld-license-compliance</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Fri, 03 Jul 2026 06:09:11 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/3e91be70-e0e1-4111-a954-3960b9d37edd_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I have always been interested in security and software supply chain topics. For a long time that felt like a specialist corner of engineering. Lately it does not. Reported vulnerabilities keep coming, open source projects spend more time on trust and project-health work, and efforts like Project Glassdoor make it harder to pretend dependency risk is somebody else&#8217;s problem. If you ship software, somebody will ask for an SBOM. The only real questions are which format they want and what they plan to do with it.</p><p>After <code>./mvnw package</code>, the file that matters in this example is:</p><pre><code><code>target/quarkus-run-spdx.json</code></code></pre><p>This is where the article splits from my earlier <a href="https://www.the-main-thread.com/p/quarkus-sbom-cyclonedx">CycloneDX SBOM walkthrough</a>. That piece was about Quarkus distribution SBOMs, dependency SBOMs, and the CycloneDX workflow around them. This one starts when security, legal, procurement, or an OSPO asks for SPDX instead. Same supply-chain conversation, different document model, and much more weight on license metadata and exchange with downstream tooling.</p><p><a href="https://spdx.dev/use/">SPDX</a> is not just another SBOM file format. It is an ISO standard, specifically <a href="https://spdx.dev/use/specifications/">ISO/IEC 5962:2021</a>. It is also where many teams first hit machine-readable license expressions, package identity, and relationship data that somebody outside the application team will read later. That part matters. A scanner wants a file. A lawyer wants declared licenses. A platform team wants identifiers and relationships they can compare across releases. A customer questionnaire wants proof that your answer came from a repeatable build instead of a copied spreadsheet and a confident shrug.</p><p>The Quarkiverse <code>quarkus-spdx</code> project gives Quarkus a native path into that world. We build a small REST service, generate an SPDX 3.0.1 SBOM during packaging, inspect the JSON-LD structure Quarkus writes, follow a declared license relationship for one dependency, and check the result with the SPDX <code>ntia-conformance-checker</code>. After that, we do a short SPDX 2.3 pass because older tools and internal workflows still care about tag-value output.</p><h2><strong>What You Need</strong></h2><p>This article uses Quarkus 3.27.2, <code>quarkus-spdx</code> 0.0.1, and Java 21. The Quarkus version stays pinned here because the current <code>quarkus-spdx</code> release was built against that line, and the article should describe a setup that packages cleanly.</p><ul><li><p>Java 25 installed</p></li><li><p>Quarkus CLI installed</p></li><li><p>Maven, or the Maven wrapper Quarkus generates</p></li><li><p><code>jq</code> for reading the SPDX JSON output</p></li><li><p>Python 3.10 or newer if you want to run <code>sbomcheck</code></p></li><li><p>Basic Quarkus REST knowledge</p></li><li><p>About two &#9749;&#65039;</p></li></ul><p>Three terms matter before the code:</p><p><strong>SPDX</strong> - a standard model for software metadata, not only a bag of package names. The current specs are <a href="https://spdx.github.io/spdx-spec/v3.0.1/">SPDX 3.0.1</a> and the older but still common <a href="https://spdx.github.io/spdx-spec/v2.3/">SPDX 2.3</a>.</p><p><strong>JSON-LD</strong> - the JSON-LD serialization used by SPDX 3. The file still looks like JSON, but the <code>@context</code> and graph model matter because relationships and element types are first-class citizens.</p><p><strong>License expression</strong> - the SPDX syntax for license statements such as <code>MIT</code>, <code>Apache-2.0</code>, or composite forms like <code>Apache-2.0 OR GPL-2.0-only</code>. SPDX makes those expressions portable. Your internal approval policy still has to decide what to do with them.</p><h2><strong>Create the Project</strong></h2><p>Create the application and follow along or <a href="https://github.com/myfear/the-main-thread/tree/main/license-ledger">check out my github repository</a>:</p><pre><code><code>quarkus create app io.mainthread:license-ledger \
  --extension='rest-jackson,io.quarkiverse.spdx:quarkus-spdx-v3:0.0.1' \
  --platform-bom=io.quarkus:quarkus-bom:3.27.2 \
  --java=25 \
  --no-code
cd license-ledger</code></code></pre><p>Use these extensions:</p><ul><li><p><code>rest-jackson</code> - JSON REST endpoints</p></li><li><p><code>io.quarkiverse.spdx:quarkus-spdx-v3:0.0.1</code> - SPDX 3.0.1 SBOM generation during the Quarkus build</p></li></ul><p>SPDX is not a platform-managed Quarkus core extension, so the version should be easy to verify later.</p><p>We also add one plain Maven dependency so the generated SBOM has something easy to recognize and inspect later. Add this to <code>pom.xml</code> inside <code>&lt;dependencies&gt;</code>:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;com.github.package-url&lt;/groupId&gt;
    &lt;artifactId&gt;packageurl-java&lt;/artifactId&gt;
    &lt;version&gt;1.5.0&lt;/version&gt;
&lt;/dependency&gt;</code></code></pre><p><code>packageurl-java</code> is a good fit here because it is small, it is directly relevant to SBOM work, and its <a href="https://central.sonatype.com/artifact/com.github.package-url/packageurl-java">Maven Central entry</a> clearly states the <code>MIT</code> license. That gives us a clean license relationship to inspect later.</p><h2><strong>Add a Small Service Worth Packaging</strong></h2><p>The service stays small. We only need enough application code that the packaged Quarkus distribution contains our own artifact plus one non-Quarkus dependency we can follow through the SPDX graph.</p><p>Create <code>src/main/java/io/mainthread/licenseledger/ComponentRequest.java</code>:</p><pre><code><code>package io.mainthread.licenseledger;

public record ComponentRequest(
        String supplier,
        String groupId,
        String artifactId,
        String version,
        String licenseExpression) {
}</code></code></pre><p>Create <code>src/main/java/io/mainthread/licenseledger/ComponentReport.java</code>:</p><pre><code><code>package io.mainthread.licenseledger;

public record ComponentReport(
        String supplier,
        String coordinate,
        String purl,
        String licenseExpression,
        String decision,
        String note) {
}</code></code></pre><p>The request stays small. The response returns the fields we care about here: coordinate, package URL, license expression, and the decision the service made.</p><p>Now add the service in <code>src/main/java/io/mainthread/licenseledger/ComponentCatalogService.java</code>:</p><pre><code><code>package io.mainthread.licenseledger;

import java.util.List;
import java.util.Set;
import java.util.TreeMap;

import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ComponentCatalogService {

    private static final Set&lt;String&gt; APPROVED_LICENSES = Set.of(
            "Apache-2.0",
            "BSD-2-Clause",
            "BSD-3-Clause",
            "MIT");

    private static final Set&lt;String&gt; REVIEW_LICENSES = Set.of(
            "EPL-2.0",
            "LGPL-2.1-only",
            "LGPL-2.1-or-later",
            "MPL-2.0");

    public List&lt;ComponentReport&gt; sampleComponents() {
        return List.of(
                review(new ComponentRequest(
                        "Quarkus",
                        "io.quarkus",
                        "quarkus-rest-jackson",
                        "3.27.2",
                        "Apache-2.0")),
                review(new ComponentRequest(
                        "Package URL",
                        "com.github.package-url",
                        "packageurl-java",
                        "1.5.0",
                        "MIT")),
                review(new ComponentRequest(
                        "Example Vendor",
                        "org.example",
                        "legacy-reports",
                        "2.4.1",
                        "GPL-2.0-only")));
    }

    public ComponentReport review(ComponentRequest request) {
        String coordinate = request.groupId() + ":" + request.artifactId() + ":" + request.version();
        String licenseExpression = normalize(request.licenseExpression());
        String decision = decisionFor(licenseExpression);
        String note = noteFor(licenseExpression, decision);

        return new ComponentReport(
                normalize(request.supplier()),
                coordinate,
                buildPurl(request),
                licenseExpression,
                decision,
                note);
    }

    private String buildPurl(ComponentRequest request) {
        try {
            return new PackageURL(
                    "maven",
                    request.groupId(),
                    request.artifactId(),
                    request.version(),
                    new TreeMap&lt;&gt;(java.util.Map.of("type", "jar")),
                    null).canonicalize();
        } catch (MalformedPackageURLException e) {
            throw new IllegalArgumentException("Invalid Maven coordinates for SPDX demo", e);
        }
    }

    private String decisionFor(String licenseExpression) {
        if (licenseExpression.contains(" OR ") || licenseExpression.contains(" WITH ")) {
            return "manual-review";
        }
        if (APPROVED_LICENSES.contains(licenseExpression)) {
            return "approved";
        }
        if (REVIEW_LICENSES.contains(licenseExpression)) {
            return "manual-review";
        }
        if (licenseExpression.contains("GPL") || licenseExpression.contains("AGPL")) {
            return "blocked";
        }
        return "manual-review";
    }

    private String noteFor(String licenseExpression, String decision) {
        if ("approved".equals(decision)) {
            return "Known SPDX identifier with a policy rule that can pass automatically.";
        }
        if ("blocked".equals(decision)) {
            return "Copyleft licenses are not rejected by SPDX, but this demo policy sends them to a hard stop.";
        }
        if (licenseExpression.contains(" OR ") || licenseExpression.contains(" WITH ")) {
            return "The SBOM can keep this SPDX expression exactly. A human still needs to decide which branch is acceptable.";
        }
        return "The SBOM stays useful, but this license still needs a reviewer before it reaches production.";
    }

    private String normalize(String value) {
        return value == null ? "" : value.trim();
    }
}</code></code></pre><p>This service does two things that matter for the article. It creates a PURL we can mention without pretending SPDX invented package identity, and it treats the SPDX license expression as data instead of reducing it to a copied string in a report. That is where the operational value starts. SPDX gives you a portable expression. Your approval policy decides how much automation you trust.</p><p>The policy is intentionally boring. <code>MIT</code> passes, <code>GPL-2.0-only</code> blocks, and composite expressions such as <code>Apache-2.0 OR GPL-2.0-only</code> go to manual review. That split matches how these discussions usually happen in real teams. A machine can tell you the expression. A human still decides whether the choice inside that expression is acceptable for a given release.</p><p>Expose the service over HTTP in <code>src/main/java/io/mainthread/licenseledger/LicenseLedgerResource.java</code>:</p><pre><code><code>package io.mainthread.licenseledger;

import java.util.List;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/components")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class LicenseLedgerResource {

    private final ComponentCatalogService service;

    public LicenseLedgerResource(ComponentCatalogService service) {
        this.service = service;
    }

    @GET
    @Path("/demo")
    public List&lt;ComponentReport&gt; demo() {
        return service.sampleComponents();
    }

    @POST
    @Path("/review")
    public ComponentReport review(ComponentRequest request) {
        return service.review(request);
    }
}</code></code></pre><p>We keep the REST layer thin here on purpose. The resource exists so the project is a normal Quarkus service. The interesting work stays in the service, where we can test the decision logic without needing HTTP for every branch.</p><h2><strong>Add Tests Before Packaging</strong></h2><p>Create <code>src/test/java/io/mainthread/licenseledger/ComponentCatalogServiceTest.java</code>:</p><pre><code><code>package io.mainthread.licenseledger;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

class ComponentCatalogServiceTest {

    private final ComponentCatalogService service = new ComponentCatalogService();

    @Test
    void shouldApproveKnownLicense() {
        ComponentReport report = service.review(new ComponentRequest(
                "Package URL",
                "com.github.package-url",
                "packageurl-java",
                "1.5.0",
                "MIT"));

        assertEquals("approved", report.decision());
        assertEquals("pkg:maven/com.github.package-url/packageurl-java@1.5.0?type=jar", report.purl());
    }

    @Test
    void shouldEscalateCompositeLicenseExpression() {
        ComponentReport report = service.review(new ComponentRequest(
                "Example Vendor",
                "org.example",
                "dual-licensed-lib",
                "2.0.0",
                "Apache-2.0 OR GPL-2.0-only"));

        assertEquals("manual-review", report.decision());
        assertTrue(report.note().contains("human"));
    }

    @Test
    void shouldBlockCopyleftLicense() {
        ComponentReport report = service.review(new ComponentRequest(
                "Example Vendor",
                "org.example",
                "legacy-reports",
                "2.4.1",
                "GPL-2.0-only"));

        assertEquals("blocked", report.decision());
        assertTrue(report.note().contains("hard stop"));
    }
}</code></code></pre><p>Add the HTTP test in <code>src/test/java/io/mainthread/licenseledger/LicenseLedgerResourceTest.java</code>:</p><pre><code><code>package io.mainthread.licenseledger;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
class LicenseLedgerResourceTest {

    @Test
    void shouldReturnSampleComponents() {
        given()
                .when().get("/components/demo")
                .then()
                .statusCode(200)
                .body("$", hasSize(3))
                .body("[0].decision", equalTo("approved"))
                .body("[2].decision", equalTo("blocked"));
    }

    @Test
    void shouldReviewSubmittedComponent() {
        given()
                .contentType("application/json")
                .body("""
                        {
                          "supplier": "Package URL",
                          "groupId": "com.github.package-url",
                          "artifactId": "packageurl-java",
                          "version": "1.5.0",
                          "licenseExpression": "MIT"
                        }
                        """)
                .when().post("/components/review")
                .then()
                .statusCode(200)
                .body("supplier", equalTo("Package URL"))
                .body("decision", equalTo("approved"))
                .body("purl", equalTo("pkg:maven/com.github.package-url/packageurl-java@1.5.0?type=jar"));
    }
}</code></code></pre><p>The unit test proves the license-policy branches. The <code>@QuarkusTest</code> proves the HTTP path and JSON mapping. That is enough for this example. We are not testing Quarkus itself, and we are not pretending a license policy engine needs a hundred lines of ceremonial coverage before it becomes credible.</p><h2><strong>Package the Application</strong></h2><p>Build the application:</p><pre><code><code>./mvnw package</code></code></pre><p>On a Java 21 runtime, the command should finish cleanly. On my workstation with Java 25 installed globally, the build still succeeds, but Quarkus test runs emit an extra JBoss Threads warning about <code>--add-opens java.base/java.lang=ALL-UNNAMED</code>. That is one more reason the article stays on Java 21.</p><p>Look at the top of <code>target/</code>:</p><pre><code><code>ls target</code></code></pre><p>You should see at least these entries:</p><pre><code><code>license-ledger-1.0.0-SNAPSHOT.jar
quarkus-app
quarkus-run-spdx.json</code></code></pre><p>That output already tells us something important. The current <code>quarkus-spdx-v3</code> release writes a file named <code>quarkus-run-spdx.json</code>. The content is SPDX 3.0.1 JSON-LD, but the filename is still <code>.json</code>, not <code>.jsonld</code>.</p><h2><strong>Read the SPDX 3 Root</strong></h2><p>Start with the document node:</p><pre><code><code>jq '.["@graph"][] | select(.type == "SpdxDocument") | {name, rootElement, profileConformance}' \
  target/quarkus-run-spdx.json</code></code></pre><p>Expected output:</p><pre><code><code>{
  "name": "quarkus-run",
  "rootElement": [
    "https://spdx.org/spdxdocs/quarkus-run-.../SBOM"
  ],
  "profileConformance": [
    "software",
    "core"
  ]
}</code></code></pre><p>That is already a different mental model from the SPDX 2 JSON many people remember. The document points at an SBOM element inside a graph, and the graph uses typed nodes such as <code>software_Sbom</code>, <code>software_Package</code>, and <code>Relationship</code>.</p><p>Now inspect the packaged root artifact:</p><pre><code><code>jq '.["@graph"][] | select((.spdxId? // "") | endswith("/SPDXRef-Package-quarkus-run.jar")) | {name, comment, hashes: [.verifiedUsing[].algorithm]}' \
  target/quarkus-run-spdx.json</code></code></pre><p>Expected shape:</p><pre><code><code>{
  "name": "quarkus-run.jar",
  "comment": "Distribution path: quarkus-run.jar",
  "hashes": [
    "sha1",
    "sha256",
    "sha512",
    "sha3_256",
    "sha3_512",
    "sha384",
    "sha3_384",
    "md5"
  ]
}</code></code></pre><p>This is a good place to stop and look carefully. The root is the runnable Quarkus distribution, not your Maven coordinates in isolation. That should feel familiar if you read the CycloneDX article, but the representation is different. In SPDX 3, the distribution artifact is a <code>software_Package</code> node with hashes and relationship edges around it.</p><p>Your own application package is still present as a separate node:</p><pre><code><code>jq '.["@graph"][] | select(.type == "software_Package" and ((.spdxId? // "") | contains("io.mainthread-license-ledger-1.0.0-SNAPSHOT"))) | {name, spdxId}' \
  target/quarkus-run-spdx.json</code></code></pre><p>Expected output:</p><pre><code><code>{
  "name": "io.mainthread:license-ledger",
  "spdxId": "https://spdx.org/spdxdocs/quarkus-run-.../SPDXRef-Package-io.mainthread-license-ledger-1.0.0-SNAPSHOT"
}</code></code></pre><p>That split matters in practice. One node is the runnable package you ship. Another node is your application artifact as part of that build graph. People often compress those into &#8220;the app&#8221; in conversation. SPDX does not.</p><h2><strong>Follow a Declared License Relationship</strong></h2><p>The graph gets more interesting when you follow a dependency to its declared license. Use this query for <code>packageurl-java</code>:</p><pre><code><code>jq '.["@graph"] as $g
  | ($g[] | select(.type == "software_Package" and ((.spdxId? // "") | contains("com.github.package-url-packageurl-java-1.5.0"))) | .spdxId) as $pkg
  | ($g[] | select(.relationshipType? == "hasDeclaredLicense" and .from == $pkg) | .to[0]) as $license
  | $g[] | select(.spdxId? == $license)
  | {type, expression: .simplelicensing_licenseExpression}' \
  target/quarkus-run-spdx.json</code></code></pre><p>Expected output:</p><pre><code><code>{
  "type": "simplelicensing_LicenseExpression",
  "expression": "MIT"
}</code></code></pre><p>This is where the topic stops being abstract for me. Once you can point to a license expression and say, &#8220;this is what our tooling can see,&#8221; the whole SBOM discussion gets more concrete. SPDX is strong here because license data is part of the model, not an awkward extra field somebody added later.</p><p>It also shows why SPDX and CycloneDX are not interchangeable just because both are SBOM formats. CycloneDX often feels closer to scanner workflows. SPDX often becomes more relevant when legal review, procurement, and standardized license expressions enter the picture. You may still want both.</p><h2><strong>Check the Result with SPDX Tooling</strong></h2><p>JSON syntax is not enough. Run an SPDX-oriented checker:</p><pre><code><code>python3 -m venv .sbomcheck
.sbomcheck/bin/pip install ntia-conformance-checker
.sbomcheck/bin/sbomcheck --sbom-spec spdx3 --comply ntia target/quarkus-run-spdx.json</code></code></pre><p>Expected output:</p><pre><code><code>2021 NTIA SBOM Minimum Elements Conformance Results

Conformant: False

Requirement                                    | Status
-------------------------------------------------------
All component names provided?                  | True
All component versions provided?               | False
All component identifiers provided?            | True
All component suppliers provided?              | False
SBOM author name provided?                     | True
SBOM creation timestamp provided?              | True
Dependency relationships provided?             | True</code></code></pre><p>Not a result anybody would put on a marketing slide but the generated SBOM is real. It carries relationships, identifiers, hashes, and declared license links. It still does not satisfy every NTIA minimum element out of the box for every component. The <a href="https://www.ntia.gov/page/software-bill-materials">NTIA minimum-elements work</a>, the SPDX <a href="https://spdx.github.io/spdx-ntia-sbom-howto/">NTIA mapping guide</a>, and the SPDX <a href="https://spdx.github.io/using/comply-with-norms/">guidance on standards and regulation</a> are the right references here. That honest result is still better than the usual &#8220;file exists, therefore compliance is done&#8221; fiction.</p><p>If you want the 2024 CISA framing check instead:</p><pre><code><code>.sbomcheck/bin/sbomcheck --sbom-spec spdx3 --comply fsct3-min target/quarkus-run-spdx.json</code></code></pre><p>On this sample, that also fails because some versions, suppliers, and concluded licenses are missing. Again, useful result. It tells you where the generated document helps and where your process still needs more data.</p><h2><strong>When SPDX 2.3 Still Wins</strong></h2><p>SPDX 3 is the more interesting model. SPDX 2.3 is still the safer answer for older tools and teams that want tag-value output.</p><p>Swap the dependency in <code>pom.xml</code>:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.quarkiverse.spdx&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-spdx-v2&lt;/artifactId&gt;
    &lt;version&gt;0.0.1&lt;/version&gt;
&lt;/dependency&gt;</code></code></pre><p>Add this to <code>src/main/resources/application.properties</code>:</p><pre><code><code>quarkus.spdx.format=all</code></code></pre><p>Build again:</p><pre><code><code>./mvnw package</code></code></pre><p>Now <code>target/</code> includes:</p><pre><code><code>quarkus-run-spdx.json
quarkus-run-spdx.spdx</code></code></pre><p>The JSON file is SPDX 2.3 JSON. The <code>.spdx</code> file is tag-value. Its first lines look like this:</p><pre><code><code>SPDXVersion: SPDX-2.3
DataLicense: CC0-1.0
DocumentNamespace: https://spdx.org/spdxdocs/quarkus-run-...
DocumentName: quarkus-run
SPDXID: SPDXRef-DOCUMENT</code></code></pre><p>This is the clearest practical split:</p><ul><li><p><code>quarkus-spdx-v3</code> gives you SPDX 3.0.1 JSON-LD with a graph model and explicit typed relationships</p></li><li><p><code>quarkus-spdx-v2</code> gives you SPDX 2.3 plus optional tag-value output, which older SPDX tooling still understands more easily</p></li></ul><p>If you already know the downstream consumer wants SPDX 2.3 or tag-value, use v2 and move on. If you want the newer model and can handle JSON-LD, v3 is the more interesting path.</p><h2><strong>SPDX vs CycloneDX in Quarkus</strong></h2><p>Here is the short version, because I already spent a full article on CycloneDX:</p><p><strong>CycloneDX</strong> is the better starting point when your main audience is scanner and CI tooling, and when you want the Quarkus-specific split between distribution SBOM and dependency SBOM that the built-in extension already gives you. If that is your problem, read <a href="https://www.the-main-thread.com/p/quarkus-sbom-cyclonedx">Create Your First Quarkus SBOM with CycloneDX</a>.</p><p><strong>SPDX</strong> gets more interesting when you need standardized license expressions, SPDX-native exchange, and a document model that legal or OSPO workflows already expect. It is also where standards and regulation discussions tend to point more often. Keep the <a href="https://spdx.dev/use/">SPDX overview</a>, the <a href="https://spdx.dev/use/specifications/">current specifications page</a>, and the <a href="https://spdx.github.io/spdx-spec/v3.0.1/">SPDX 3.0.1 spec</a> open while you decide.</p><p>In real organizations, &#8220;pick one forever&#8221; is often the wrong question. One format may feed scanners better. Another may fit compliance and disclosure workflows better. If your release process has to satisfy both groups, generating both is less painful than arguing about which department owns reality.</p><h2><strong>Conclusion</strong></h2><p>We built a small Quarkus service, generated an SPDX SBOM with <code>quarkus-spdx</code>, inspected the SPDX 3.0.1 JSON-LD graph, followed a declared license expression for one dependency, and checked the result against NTIA and CISA minimum expectations. The useful mental shift is this: an SPDX file is not only an inventory. It is a graph of software elements, identities, relationships, and licenses that other teams will read long after your build finished.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p>]]></content:encoded></item><item><title><![CDATA[Build a Refund Workflow with Quarkus Flow 0.11]]></title><description><![CDATA[Build a small refund service that keeps policy in plain Java and lets Quarkus Flow handle CloudEvents, correlation, waiting state, and restart-safe workflow coordination.]]></description><link>https://www.the-main-thread.com/p/quarkus-flow-0-11-workflows-not-rules</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-flow-0-11-workflows-not-rules</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Thu, 02 Jul 2026 06:08:07 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/17ad176a-bb58-47fa-b68a-1fce3a03d79d_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I wrote about Quarkus Flow before and if you want to start fresh, you might wanna catch up on that before you dive deeper with this piece.</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;c8111af0-ec36-4c45-9f6d-6d1abed3b6d2&quot;,&quot;caption&quot;:&quot;This lab is a complete, runnable application. It shows the workflow pattern most Java teams actually need in production.&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Stop Gluing Scripts Together: Build a Real Java Workflow with Quarkus Flow, Kafka, and Ollama&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:72758027,&quot;name&quot;:&quot;Markus Eisele&quot;,&quot;bio&quot;:&quot;I&#8217;ve spent 20+ years helping Java systems adapt without breaking. Here I share the architecture, tools, and thinking behind that work. Java Champion &#183; Developer &#183; IBM Research.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/00dcb2da-5c09-46bf-a265-2a22ed32250b_800x800.png&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-03-02T07:08:08.030Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7aeee450-886d-4296-8ea5-1af86c18240c_1536x1024.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://www.the-main-thread.com/p/quarkus-flow-ollama-kafka-cloudevents-java-workflow&quot;,&quot;section_name&quot;:null,&quot;video_upload_id&quot;:null,&quot;id&quot;:187825169,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:10,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4194688,&quot;publication_name&quot;:&quot;The Main Thread&quot;,&quot;publication_logo_url&quot;:&quot;https://substackcdn.com/image/fetch/$s_!8sdd!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F81643b8a-6240-4cd1-9f3a-8fd19cc3a455_254x254.png&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p>A refund request can look simple on first read:</p><pre><code><code>if amount is small and the receipt exists, approve it
if the receipt is missing, deny it
otherwise ask a human</code></code></pre><p>That is a policy decision. It belongs in boring, testable code. If your team already uses a rules engine, that policy can live there too.</p><p>The awkward part starts after the policy says &#8220;ask a human.&#8221; Now the system has to emit a review request, remember which workflow instance is waiting, accept a callback later, resume the exact request, record the final decision, and leave enough evidence for the next person who has to debug it. A decision table does not want that job. A workflow engine does.</p><p>This is where <a href="https://docs.quarkiverse.io/quarkus-flow/dev/index.html">Quarkus Flow</a> fits. It is a lightweight workflow engine for Quarkus based on the CNCF Serverless Workflow specification. Version <a href="https://github.com/quarkiverse/quarkus-flow/releases/tag/0.11.0">0.11.0</a>, adds enough new pieces that it deserves a different article than another &#8220;call three services in order&#8221; walkthrough: runner images, better idempotency and correlation guidance, runtime-configurable messaging and observability pieces, Quartz scheduling, <code>@ScheduleOn</code> for agentic workflows, and gRPC channel routing. </p><p>We will build <strong>RefundDesk</strong>, a small Quarkus service that shows the boundary:</p><ul><li><p>A plain Java policy decides whether a refund is approved, denied, or routed to manual review</p></li><li><p>A Quarkus Flow workflow coordinates the process around that decision</p></li><li><p>CloudEvents carry the manual-review request and callback</p></li><li><p>The workflow pauses and resumes by correlation data</p></li><li><p>Tests prove the policy and the HTTP path</p></li></ul><p>No AI is needed for the main path. That is intentional. A workflow engine is already useful before the model shows up and starts making confident little messes. But, yes, Quarkus Flow also works with LangChain4j and the agentic modules. But this will be another article soon hopefully.</p><h2><strong>What You Need</strong></h2><p>This article uses Quarkus <code>3.37.0</code>, Quarkus Flow <code>0.11.0</code>, Java 25, and Podman. </p><ul><li><p>Java 25 installed</p></li><li><p>Quarkus CLI installed</p></li><li><p>Podman available for Kafka Dev Services and the Runner image</p></li><li><p>Basic Quarkus REST and CDI knowledge</p></li><li><p>About two &#9749;&#65039; (and yes, you can also take another beverage depending on time of the day and mood.)</p></li></ul><p>Three definitions before we start to code:</p><p><strong>Policy</strong> - the deterministic decision logic. In this article, it is a Java CDI bean. It could also be Drools, Easy Rules, or a remote decision service.</p><p><strong>Workflow</strong> - the process that decides which step runs next and what state must survive across waiting, retries, and callbacks.</p><p><strong>Correlation</strong> - the data that lets an incoming callback wake the right waiting workflow instance. In Quarkus Flow event workflows, the <code>flowinstanceid</code> CloudEvent extension is the important one.</p><h2><strong>Create the Project</strong></h2><p>Create the application and follow along or grab the source code from my repository. :</p><pre><code><code>quarkus create app dev.mainthread:refunddesk-flow \
  --extension='io.quarkiverse.flow:quarkus-flow:0.11.0,io.quarkiverse.flow:quarkus-flow-mvstore:0.11.0,rest-jackson,messaging-kafka,smallrye-openapi,quarkus-micrometer-registry-prometheus' \
  --java=25 \
  --no-code
cd refunddesk-flow</code></code></pre><p>Use these extensions:</p><ul><li><p><code>quarkus-flow</code>: the Java DSL and workflow runtime</p></li><li><p><code>quarkus-flow-mvstore</code>: local file-backed workflow persistence</p></li><li><p><code>rest-jackson</code>: HTTP endpoints and JSON serialization</p></li><li><p><code>quarkus-micrometer-registry-prometheus</code> - has to be included when Quarkus Flow metrics are enabled.</p></li><li><p><code>messaging-kafka</code>: the Reactive Messaging bridge that connects Flow to Kafka topics</p></li><li><p><code>smallrye-openapi</code>: a quick way to inspect the REST API while testing</p></li></ul><p>The important detail is that Flow is scaffolded by the Quarkus CLI. Do not paste a second Flow dependency block into the POM. The command above asks the CLI to add both Flow artifacts while the project is created.</p><p>I am using the full Quarkiverse coordinates because, when I tested this, the catalog short name did not resolve to the 0.11.0 artifact. Once the catalog catches up, <code>--extension='quarkus-flow,...'</code> is the nicer shape. Until then, pinning <code>io.quarkiverse.flow:quarkus-flow:0.11.0</code> keeps the tutorial reproducible.</p><p><code>quarkus-flow-mvstore</code> gives this local tutorial a file-backed persistence store so waiting workflow state can survive a JVM restart. MVStore is a good local fit. For production, you will usually move to Redis or JPA, depending on how your platform already stores operational state.</p><h2><strong>Define the Refund Model</strong></h2><p>The policy and workflow need a small domain model. Keep it explicit. If the records are too loose, the workflow becomes a bag of maps and the policy test stops telling you much.</p><p>Create <code>src/main/java/dev/mainthread/refunddesk/DecisionOutcome.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

public enum DecisionOutcome {
    APPROVED,
    DENIED,
    MANUAL_REVIEW
}</code></code></pre><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundRequest.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import java.math.BigDecimal;

public record RefundRequest(
        String refundId,
        String customerId,
        BigDecimal amount,
        boolean receiptPresent,
        int accountAgeDays,
        int chargebackCount) {
}</code></code></pre><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundDecision.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

public record RefundDecision(DecisionOutcome outcome, String reason) {
}</code></code></pre><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundCase.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

public record RefundCase(RefundRequest request, RefundDecision decision) {
}</code></code></pre><p>Create <code>src/main/java/dev/mainthread/refunddesk/ReviewDecision.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

public record ReviewDecision(
        String refundId,
        DecisionOutcome outcome,
        String reviewer,
        String note) {
}</code></code></pre><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundResult.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

public record RefundResult(
        String refundId,
        DecisionOutcome outcome,
        String reason,
        String reviewer) {
}</code></code></pre><p>Create <code>src/main/java/dev/mainthread/refunddesk/SubmitRefundResponse.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

public record SubmitRefundResponse(String refundId, String workflowInstanceId) {
}</code></code></pre><p>These records separate facts, policy output, review output, and final state. That keeps the policy easy to test and the workflow easier to read. A rules engine would use the same kind of boundary: facts in, decision out.</p><h2><strong>Write the Policy as Plain Java</strong></h2><p>The policy is deliberately small. The goal is to show where the rule belongs, not to pretend three predicates are a credit-risk platform.</p><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundPolicy.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import java.math.BigDecimal;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class RefundPolicy {

    private static final BigDecimal AUTO_APPROVE_LIMIT = new BigDecimal("50.00");
    private static final BigDecimal MANUAL_REVIEW_LIMIT = new BigDecimal("300.00");

    public RefundCase evaluate(RefundRequest request) {
        if (!request.receiptPresent()) {
            return new RefundCase(request,
                    new RefundDecision(DecisionOutcome.DENIED, "receipt is missing"));
        }

        if (request.amount().compareTo(AUTO_APPROVE_LIMIT) &lt;= 0 &amp;&amp; request.chargebackCount() == 0) {
            return new RefundCase(request,
                    new RefundDecision(DecisionOutcome.APPROVED, "small refund with clean history"));
        }

        if (request.amount().compareTo(MANUAL_REVIEW_LIMIT) &gt;= 0
                || request.accountAgeDays() &lt; 30
                || request.chargebackCount() &gt; 1) {
            return new RefundCase(request,
                    new RefundDecision(DecisionOutcome.MANUAL_REVIEW, "manual review required"));
        }

        return new RefundCase(request,
                new RefundDecision(DecisionOutcome.APPROVED, "standard refund approved"));
    }
}</code></code></pre><p>This class has no workflow dependency. That is the point. You can unit test it without Kafka, Flow, REST, or a running Quarkus application.</p><p>If your organization already has a rules engine, this class is the seam where it would go. The workflow only cares that <code>evaluate()</code> returns a <code>RefundCase</code>, whether the decision came from Drools, a DMN service, or a <code>switch</code> statement.</p><h2><strong>Store Decisions</strong></h2><p>We need a small store so the HTTP API can show the final result after the workflow finishes. This uses memory because the tutorial is about workflow state, not database schema design. In a real refund system, this would be a database table with a uniqueness constraint on <code>refundId</code>.</p><p>Create <code>src/main/java/dev/mainthread/refunddesk/DecisionStore.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class DecisionStore {

    private final ConcurrentMap&lt;String, RefundResult&gt; results = new ConcurrentHashMap&lt;&gt;();

    public void recordAutomatic(RefundCase refundCase) {
        RefundRequest request = refundCase.request();
        RefundDecision decision = refundCase.decision();

        results.put(request.refundId(),
                new RefundResult(request.refundId(), decision.outcome(), decision.reason(), "policy"));
    }

    public void recordManualReview(ReviewDecision review) {
        if (DecisionOutcome.MANUAL_REVIEW.equals(review.outcome())) {
            throw new IllegalArgumentException("A reviewer must approve or deny the refund");
        }

        results.put(review.refundId(),
                new RefundResult(review.refundId(), review.outcome(), review.note(), review.reviewer()));
    }

    public Optional&lt;RefundResult&gt; find(String refundId) {
        return Optional.ofNullable(results.get(refundId));
    }
}</code></code></pre><p>There is one production warning worth saying here, and only here: <strong>this store is not an idempotency boundary.</strong> A real refund system should enforce one final decision per <code>refundId</code> in the database. Quarkus Flow can propagate correlation data, but the receiver of a side effect still owns deduplication.</p><p>The <a href="https://docs.quarkiverse.io/quarkus-flow/dev/idempotency-correlation.html">Quarkus Flow idempotency and correlation guide</a> is clear about the limit: Flow gives every workflow instance a unique identity and propagates correlation metadata for HTTP and CloudEvents. Exactly-once behavior still belongs at the receiver, with stable business keys and storage constraints. Anything claiming otherwise usually hides the invoice in another room.</p><h2><strong>Build the Workflow</strong></h2><p>Now we can write the orchestration. It has five moves:</p><ol><li><p>Run the policy</p></li><li><p>Branch on the policy result</p></li><li><p>Record automatic decisions</p></li><li><p>Emit a review request for manual cases</p></li><li><p>Wait for a review callback and record it</p></li></ol><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundWorkflow.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.consume;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.consumed;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.emitJson;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.function;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.listen;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.switchWhenOrElse;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.toOne;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkiverse.flow.Flow;
import io.serverlessworkflow.api.types.FlowDirectiveEnum;
import io.serverlessworkflow.api.types.Workflow;
import io.serverlessworkflow.fluent.func.FuncWorkflowBuilder;

@ApplicationScoped
public class RefundWorkflow extends Flow {

    private final RefundPolicy policy;
    private final DecisionStore decisions;

    public RefundWorkflow(RefundPolicy policy, DecisionStore decisions) {
        this.policy = policy;
        this.decisions = decisions;
    }

    @Override
    public Workflow descriptor() {
        return FuncWorkflowBuilder.workflow("refund-review", "refunddesk", "1.0.0")
                .tasks(
                        function("evaluatePolicy", policy::evaluate, RefundRequest.class),
                        switchWhenOrElse(this::needsManualReview, "requestManualReview", "finishAutomatic",
                                RefundCase.class),
                        emitJson("requestManualReview", "refund.review.required", RefundCase.class),
                        listen("waitForReview",
                                toOne(consumed("refund.review.completed").extensionByInstanceId("flowinstanceid"))),
                        consume("finishManualReview", decisions::recordManualReview, ReviewDecision.class)
                                .then(FlowDirectiveEnum.END),
                        consume("finishAutomatic", decisions::recordAutomatic, RefundCase.class)
                                .then(FlowDirectiveEnum.END))
                .build();
    }

    private boolean needsManualReview(RefundCase refundCase) {
        return DecisionOutcome.MANUAL_REVIEW.equals(refundCase.decision().outcome());
    }
}</code></code></pre><p>This is where the workflow actually happens.</p><p><code>function("evaluatePolicy", ...)</code> calls normal Java code. The result becomes the workflow data for the next step. <code>switchWhenOrElse(...)</code> decides which task name runs next. Automatic decisions jump to <code>finishAutomatic</code>. Manual-review decisions jump to <code>requestManualReview</code>.</p><p><code>emitJson(...)</code> publishes a structured CloudEvent to the Flow outbound channel. In a real system, a reviewer UI or back-office queue would consume that event. Then <code>listen(...)</code> pauses the workflow until a <code>refund.review.completed</code> CloudEvent reaches the engine.</p><p>The correlation part is in this line:</p><pre><code><code>toOne(consumed("refund.review.completed").extensionByInstanceId("flowinstanceid"))</code></code></pre><p>Flow will only resume this waiting workflow when the callback event has the matching <code>flowinstanceid</code> CloudEvent extension. That is the difference between &#8220;some review happened&#8221; and &#8220;the review for this workflow instance happened.&#8221;</p><p>The two <code>then(FlowDirectiveEnum.END)</code> calls are boring and easy to miss. After a manual review is recorded, the workflow must end. After an automatic decision is recorded, it must end. If you remove those endings, you have built a confusing process with excellent syntax highlighting.</p><h2><strong>Expose the Workflow Over HTTP</strong></h2><p>The REST resource starts workflow instances, reads final decisions, and turns reviewer callbacks into CloudEvents.</p><p>Create <code>src/main/java/dev/mainthread/refunddesk/RefundResource.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;

import java.net.URI;
import java.util.Map;
import java.util.UUID;

import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import io.serverlessworkflow.impl.WorkflowInstance;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;

@Path("/refunds")
@ApplicationScoped
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public class RefundResource {

    private static final JsonFormat CE_JSON = (JsonFormat) EventFormatProvider.getInstance()
            .resolveFormat(JsonFormat.CONTENT_TYPE);

    @Inject
    RefundWorkflow workflow;

    @Inject
    DecisionStore decisions;

    @Inject
    ObjectMapper objectMapper;

    @Inject
    @Channel("flow-in-outgoing")
    Emitter&lt;byte[]&gt; flowIn;

    @POST
    public Response submit(RefundRequest request) {
        WorkflowInstance instance = workflow.instance(request);
        instance.start();

        return Response.accepted(new SubmitRefundResponse(request.refundId(), instance.id())).build();
    }

    @GET
    @Path("/{refundId}")
    public Response result(@PathParam("refundId") String refundId) {
        return decisions.find(refundId)
                .map(result -&gt; Response.ok(result).build())
                .orElseGet(() -&gt; Response.status(Status.NOT_FOUND).build());
    }

    @POST
    @Path("/{refundId}/review/{instanceId}")
    public Response review(@PathParam("refundId") String refundId,
            @PathParam("instanceId") String instanceId,
            ReviewDecision review) throws JsonProcessingException {

        if (!refundId.equals(review.refundId())) {
            return Response.status(Status.BAD_REQUEST)
                    .entity(Map.of("error", "path refundId and review refundId must match"))
                    .build();
        }

        byte[] body = objectMapper.writeValueAsBytes(review);
        byte[] event = CE_JSON.serialize(CloudEventBuilder.v1()
                .withId(UUID.randomUUID().toString())
                .withSource(URI.create("urn:refunddesk:review-api"))
                .withType("refund.review.completed")
                .withDataContentType(APPLICATION_JSON)
                .withExtension("flowinstanceid", instanceId)
                .withData(body)
                .build());

        flowIn.send(event);
        return Response.accepted().build();
    }
}</code></code></pre><p>The callback endpoint writes a structured CloudEvent as JSON bytes. Notice the <code>flowinstanceid</code> extension. That value is the routing key for the waiting workflow instance.</p><p>The business key still matters. We keep <code>refundId</code> in the URL and in the event data because your domain system should know which refund was reviewed. The workflow instance ID wakes the process. The refund ID protects the business record.</p><p>Also notice that the callback writes to a channel named <code>flow-in-outgoing</code>, not directly to <code>flow-in</code>. In the configuration below, <code>flow-in-outgoing</code> is an outgoing producer that writes to the same Kafka topic that Flow consumes as <code>flow-in</code>. Keeping the app producer and Flow consumer as separate Reactive Messaging channels makes the wiring easier to reason about.</p><h2><strong>Configure Messaging, Persistence, and Tracing</strong></h2><p>Configure the application in <code>src/main/resources/application.properties</code>:</p><pre><code><code>quarkus.application.name=refunddesk-flow

quarkus.flow.messaging.defaults-enabled=true

mp.messaging.incoming.flow-in.connector=smallrye-kafka
mp.messaging.incoming.flow-in.topic=refunddesk-flow-in
mp.messaging.incoming.flow-in.value.deserializer=org.apache.kafka.common.serialization.ByteArrayDeserializer
mp.messaging.incoming.flow-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.flow-in.auto.offset.reset=earliest

mp.messaging.outgoing.flow-out.connector=smallrye-kafka
mp.messaging.outgoing.flow-out.topic=refunddesk-flow-out
mp.messaging.outgoing.flow-out.value.serializer=org.apache.kafka.common.serialization.ByteArraySerializer
mp.messaging.outgoing.flow-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer

mp.messaging.outgoing.flow-in-outgoing.connector=smallrye-kafka
mp.messaging.outgoing.flow-in-outgoing.topic=refunddesk-flow-in
mp.messaging.outgoing.flow-in-outgoing.value.serializer=org.apache.kafka.common.serialization.ByteArraySerializer
mp.messaging.outgoing.flow-in-outgoing.key.serializer=org.apache.kafka.common.serialization.StringSerializer

quarkus.flow.persistence.mvstore.db-path=target/refunddesk-flow.mv.db

%dev.quarkus.flow.tracing.enabled=true
%test.quarkus.flow.tracing.enabled=true</code></code></pre><p><code>quarkus.flow.messaging.defaults-enabled=true</code> turns on the Flow messaging bridge. Flow reads structured CloudEvents from <code>flow-in</code> and publishes workflow events to <code>flow-out</code>. Because <code>messaging-kafka</code> is on the classpath, Quarkus Dev Services starts Kafka for local development when no broker is configured.</p><p>The <code>flow-in-outgoing</code> channel writes to the same <code>refunddesk-flow-in</code> topic. That lets the REST callback endpoint wake the waiting workflow through the same external event path a real reviewer UI would use.</p><p>The MVStore path keeps workflow checkpoints in <code>target/</code>. This is fine for a tutorial and local testing. For production, put workflow state in a durable store outside the application filesystem.</p><p>Tracing is enabled in dev and test so you can see which workflow and task ran. Quarkus Flow&#8217;s <a href="https://docs.quarkiverse.io/quarkus-flow/dev/tracing.html">tracing docs</a> also show how to emit MDC fields for log correlation. The short version: use the workflow instance ID when you search logs. It is the thread that ties the process together after the process stops being one HTTP request.</p><h2><strong>Run the App</strong></h2><p>Start dev mode:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>Submit a small refund:</p><pre><code><code>curl -i -X POST http://localhost:8080/refunds \
  -H 'Content-Type: application/json' \
  -d '{
    "refundId": "refund-1001",
    "customerId": "customer-7",
    "amount": 42.00,
    "receiptPresent": true,
    "accountAgeDays": 120,
    "chargebackCount": 0
  }'</code></code></pre><p>Expected response:</p><pre><code><code>{
  "refundId": "refund-1001",
  "workflowInstanceId": "01J..."
}</code></code></pre><p>The endpoint always returns <code>202 Accepted</code> because the workflow may finish immediately or may wait for a later event. That stable contract is easier for clients. The result lives behind <code>GET /refunds/{refundId}</code>.</p><p>Check the result:</p><pre><code><code>curl http://localhost:8080/refunds/refund-1001</code></code></pre><p>Expected response:</p><pre><code><code>{
  "refundId": "refund-1001",
  "outcome": "APPROVED",
  "reason": "small refund with clean history",
  "reviewer": "policy"
}</code></code></pre><p>Try a denied refund:</p><pre><code><code>curl -i -X POST http://localhost:8080/refunds \
  -H 'Content-Type: application/json' \
  -d '{
    "refundId": "refund-1002",
    "customerId": "customer-8",
    "amount": 19.99,
    "receiptPresent": false,
    "accountAgeDays": 400,
    "chargebackCount": 0
  }'

curl http://localhost:8080/refunds/refund-1002</code></code></pre><p>Expected result:</p><pre><code><code>{
  "refundId": "refund-1002",
  "outcome": "DENIED",
  "reason": "receipt is missing",
  "reviewer": "policy"
}</code></code></pre><p>Now submit a refund that needs a person:</p><pre><code><code>curl -i -X POST http://localhost:8080/refunds \
  -H 'Content-Type: application/json' \
  -d '{
    "refundId": "refund-1003",
    "customerId": "customer-9",
    "amount": 450.00,
    "receiptPresent": true,
    "accountAgeDays": 15,
    "chargebackCount": 2
  }'</code></code></pre><p>Keep the <code>workflowInstanceId</code> from the response. Before the review arrives, the result is not ready:</p><pre><code><code>curl -i http://localhost:8080/refunds/refund-1003</code></code></pre><p>Expected status:</p><pre><code><code>HTTP/1.1 404 Not Found</code></code></pre><p>For the manual curl path, wait until the workflow has reached the review step before sending the callback. In dev mode you will see <code>requestManualReview</code> complete and <code>waitForReview</code> start in the log. A real reviewer UI would not guess this timing; it would consume the <code>refund.review.required</code> event from <code>flow-out</code>, store its <code>flowinstanceid</code>, and use that value for the callback.</p><p>Send the review callback. Replace <code>01J...</code> with the workflow instance ID returned by the submit call:</p><pre><code><code>curl -i -X POST http://localhost:8080/refunds/refund-1003/review/01J... \
  -H 'Content-Type: application/json' \
  -d '{
    "refundId": "refund-1003",
    "outcome": "APPROVED",
    "reviewer": "alex",
    "note": "receipt and order history checked"
  }'</code></code></pre><p>Check the result again:</p><pre><code><code>curl http://localhost:8080/refunds/refund-1003</code></code></pre><p>Expected response:</p><pre><code><code>{
  "refundId": "refund-1003",
  "outcome": "APPROVED",
  "reason": "receipt and order history checked",
  "reviewer": "alex"
}</code></code></pre><p>That is the workflow shape a rules engine does not cover by itself. The policy decision was one task. The process around the decision crossed an event boundary, waited, resumed, and recorded the final state.</p><h2><strong>Test the Policy First</strong></h2><p>Test the policy outside Quarkus. This is a normal unit test, and that is a feature.</p><p>Create <code>src/test/java/dev/mainthread/refunddesk/RefundPolicyTest.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.math.BigDecimal;

import org.junit.jupiter.api.Test;

class RefundPolicyTest {

    private final RefundPolicy policy = new RefundPolicy();

    @Test
    void approvesSmallCleanRefund() {
        RefundCase refundCase = policy.evaluate(new RefundRequest(
                "refund-2001",
                "customer-1",
                new BigDecimal("25.00"),
                true,
                120,
                0));

        assertEquals(DecisionOutcome.APPROVED, refundCase.decision().outcome());
        assertEquals("small refund with clean history", refundCase.decision().reason());
    }

    @Test
    void deniesMissingReceipt() {
        RefundCase refundCase = policy.evaluate(new RefundRequest(
                "refund-2002",
                "customer-2",
                new BigDecimal("25.00"),
                false,
                120,
                0));

        assertEquals(DecisionOutcome.DENIED, refundCase.decision().outcome());
    }

    @Test
    void routesSuspiciousRefundToManualReview() {
        RefundCase refundCase = policy.evaluate(new RefundRequest(
                "refund-2003",
                "customer-3",
                new BigDecimal("450.00"),
                true,
                10,
                2));

        assertEquals(DecisionOutcome.MANUAL_REVIEW, refundCase.decision().outcome());
    }
}</code></code></pre><p>Run it:</p><pre><code><code>./mvnw test -Dtest=RefundPolicyTest</code></code></pre><p>This test does not prove the workflow. It proves the decision logic. That separation keeps the article honest: a rules engine would sit at the same boundary.</p><h2><strong>Test the Workflow Through HTTP</strong></h2><p>Now test the process. This test starts the app, submits refunds, reads the emitted review request from <code>flow-out</code>, sends a review callback, and polls for the final result.</p><p>Add two test dependencies to <code>pom.xml</code>:</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;io.quarkus&lt;/groupId&gt;
    &lt;artifactId&gt;quarkus-test-kafka-companion&lt;/artifactId&gt;
    &lt;scope&gt;test&lt;/scope&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.awaitility&lt;/groupId&gt;
    &lt;artifactId&gt;awaitility&lt;/artifactId&gt;
    &lt;scope&gt;test&lt;/scope&gt;
&lt;/dependency&gt;</code></code></pre><p>Create <code>src/test/resources/application.properties</code>:</p><pre><code><code>quarkus.flow.messaging.defaults-enabled=true

mp.messaging.incoming.flow-in.auto.offset.reset=earliest

quarkus.flow.persistence.mvstore.db-path=target/refunddesk-flow-test.mv.db
quarkus.flow.persistence.auto-restore=false
quarkus.flow.tracing.enabled=true</code></code></pre><p>Create <code>src/test/java/dev/mainthread/refunddesk/RefundResourceTest.java</code>:</p><pre><code><code>package dev.mainthread.refunddesk;

import static io.restassured.RestAssured.given;
import static java.time.Duration.ofSeconds;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;

import java.math.BigDecimal;
import java.util.concurrent.atomic.AtomicReference;

import jakarta.inject.Inject;

import org.junit.jupiter.api.Test;
import org.apache.kafka.common.serialization.ByteArrayDeserializer;
import org.apache.kafka.common.serialization.StringDeserializer;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.cloudevents.CloudEvent;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.kafka.InjectKafkaCompanion;
import io.quarkus.test.kafka.KafkaCompanionResource;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
import io.smallrye.reactive.messaging.kafka.companion.ConsumerTask;
import io.smallrye.reactive.messaging.kafka.companion.KafkaCompanion;

@QuarkusTest
@QuarkusTestResource(KafkaCompanionResource.class)
class RefundResourceTest {

    private static final JsonFormat CE_JSON = (JsonFormat) EventFormatProvider.getInstance()
            .resolveFormat(JsonFormat.CONTENT_TYPE);

    @InjectKafkaCompanion
    KafkaCompanion companion;

    @Inject
    ObjectMapper objectMapper;

    @Test
    void autoApprovesSmallRefund() {
        given()
                .contentType(ContentType.JSON)
                .body(new RefundRequest("refund-test-1", "customer-1", new BigDecimal("42.00"), true, 120, 0))
                .when()
                .post("/refunds")
                .then()
                .statusCode(202);

        Response result = waitForResult("refund-test-1");

        result.then()
                .statusCode(200)
                .body("outcome", equalTo("APPROVED"))
                .body("reviewer", equalTo("policy"));
    }

    @Test
    void resumesManualReviewFromCallbackEvent() throws Exception {
        ConsumerTask&lt;Object, Object&gt; flowOut = companion
                .consumeWithDeserializers(StringDeserializer.class, ByteArrayDeserializer.class)
                .fromTopics("refunddesk-flow-out");

        given()
                .contentType(ContentType.JSON)
                .body(new RefundRequest("refund-test-2", "customer-2", new BigDecimal("450.00"), true, 10, 2))
                .when()
                .post("/refunds")
                .then()
                .statusCode(202);

        CloudEvent reviewRequired = waitForReviewRequiredEvent(flowOut);
        assertEquals("refund.review.required", reviewRequired.getType());
        assertNotNull(reviewRequired.getExtension("flowinstanceid"));

        RefundCase refundCase = objectMapper.readValue(reviewRequired.getData().toBytes(), RefundCase.class);
        assertEquals("refund-test-2", refundCase.request().refundId());

        given()
                .contentType(ContentType.JSON)
                .body(new ReviewDecision("refund-test-2", DecisionOutcome.APPROVED, "test-reviewer", "checked"))
                .when()
                .post("/refunds/{refundId}/review/{instanceId}",
                        "refund-test-2",
                        reviewRequired.getExtension("flowinstanceid").toString())
                .then()
                .statusCode(202);

        Response result = waitForResult("refund-test-2");

        result.then()
                .statusCode(200)
                .body("outcome", equalTo("APPROVED"))
                .body("reviewer", equalTo("test-reviewer"));

        flowOut.close();
    }

    @Test
    void rejectsMismatchedReviewCallback() {
        given()
                .contentType(ContentType.JSON)
                .body(new ReviewDecision("other-refund", DecisionOutcome.APPROVED, "test-reviewer", "checked"))
                .when()
                .post("/refunds/{refundId}/review/{instanceId}",
                        "refund-test-3",
                        "01JFAKEINSTANCE")
                .then()
                .statusCode(400)
                .body("error", equalTo("path refundId and review refundId must match"));
    }

    private Response waitForResult(String refundId) {
        for (int attempt = 0; attempt &lt; 50; attempt++) {
            Response response = given()
                    .accept(ContentType.JSON)
                    .when()
                    .get("/refunds/{refundId}", refundId);

            if (response.statusCode() == 200) {
                return response;
            }

            sleep();
        }

        fail("Timed out waiting for refund result " + refundId);
        return null;
    }

    private void sleep() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        }
    }

    private CloudEvent waitForReviewRequiredEvent(ConsumerTask&lt;Object, Object&gt; flowOut) {
        AtomicReference&lt;CloudEvent&gt; reviewRequired = new AtomicReference&lt;&gt;();

        await().atMost(ofSeconds(10)).untilAsserted(() -&gt; {
            flowOut.stream().forEach(record -&gt; {
                CloudEvent event = CE_JSON.deserialize((byte[]) record.value());
                if ("refund.review.required".equals(event.getType())) {
                    reviewRequired.set(event);
                }
            });
            assertNotNull(reviewRequired.get());
        });

        return reviewRequired.get();
    }
}</code></code></pre><p>Run all tests:</p><pre><code><code>./mvnw test</code></code></pre><p>The manual-review test deliberately consumes <code>refund.review.required</code> before it sends the callback. That order matters. The event proves the workflow reached the wait point and gives the test the same <code>flowinstanceid</code> a reviewer UI would store with its work item.</p><h2><strong>What Flow Guarantees, and What It Does Not</strong></h2><p>The workflow now waits and resumes, but the boundary is still important.</p><p>Quarkus Flow assigns each workflow instance a unique ID. It can propagate that ID through HTTP headers and CloudEvent extension attributes. In our callback event, <code>flowinstanceid</code> tells Flow which waiting instance to resume.</p><p>Flow treats independent workflow instances as independent work. If the same <code>refund.requested</code> event arrives twice and you start two workflows, you have two workflow instances. Your application still needs a business idempotency key such as <code>refundId</code> and a database constraint that makes duplicate final decisions impossible.</p><p>Flow also gives you at-least-once task execution, not exactly-once execution. If a task calls an external service and the network fails at the wrong moment, the receiver must be safe to call again. Use stable business keys for operations such as payments, refunds, ledger writes, and emails.</p><p>That may sound disappointing until you compare it with the alternative: hiding the same distributed-systems problem behind a rule called <code>approveRefund</code>. At least here the boundary is visible.</p><h2><strong>Make the Workflow Survive a Restart</strong></h2><p>With <code>quarkus-flow-mvstore</code> on the classpath and the MVStore path configured, Flow checkpoints state after completed tasks and paused steps. You can test the practical effect:</p><ol><li><p>Start the app with <code>./mvnw quarkus:dev</code></p></li><li><p>Submit <code>refund-1003</code>, the manual-review case</p></li><li><p>Stop dev mode with <code>Ctrl+C</code></p></li><li><p>Start dev mode again</p></li><li><p>Send the review callback using the same <code>workflowInstanceId</code> from the curl response, or the <code>flowinstanceid</code> from <code>flow-out</code> if you are testing through a reviewer event consumer</p></li><li><p>Read <code>GET /refunds/refund-1003</code></p></li></ol><p>The waiting workflow should resume because the workflow state was persisted. The <code>DecisionStore</code> in this article is still in memory, so it only records the final result after the callback arrives. If you want the submitted refund record itself to survive restarts, put the domain state in a database too.</p><p>For production, the <a href="https://docs.quarkiverse.io/quarkus-flow/dev/persistence.html">Quarkus Flow persistence guide</a> gives three provider paths:</p><ul><li><p>Redis for distributed workloads</p></li><li><p>JPA for relational databases</p></li><li><p>MVStore for local development, tests, and single-node deployments</p></li></ul><p>Pick one persistence provider. Running several persistence implementations in the same app is a good way to make future-you read source code with bad coffee.</p><h2><strong>Use the Runner When the Workflow Is Configuration</strong></h2><p>The Java DSL is the better fit for RefundDesk because the workflow calls CDI beans and domain code. Quarkus Flow 0.11.0 also ships pre-built <a href="https://docs.quarkiverse.io/quarkus-flow/dev/reference-runner-images.html">Runner images</a> for a different case: you have workflow definitions as YAML or JSON and want to run them as configuration.</p><p>Use the Runner when a platform team wants to mount workflows from GitOps, ConfigMaps, or another external workflow authoring path. Use the Java DSL when the workflow is tightly coupled to Quarkus beans and you want compile-time help.</p><p>Create a small policy-only workflow file under a new local directory:</p><pre><code><code>mkdir -p workflows</code></code></pre><p>Create <code>workflows/refund-policy.yaml</code>:</p><pre><code><code>document:
  dsl: "1.0.0"
  namespace: refunddesk
  name: refund-policy
  version: "1.0.0"
input:
  schema:
    format: json
    document:
      type: object
      required:
        - amount
        - receiptPresent
        - chargebackCount
      properties:
        amount:
          type: number
        receiptPresent:
          type: boolean
        chargebackCount:
          type: integer
do:
  - route:
      switch:
        - missingReceipt:
            when: .receiptPresent == false
            then: deny
        - smallClean:
            when: .amount &lt;= 50 and .chargebackCount == 0
            then: approve
        - default:
            then: manualReview
  - approve:
      set:
        outcome: APPROVED
        reason: small refund with clean history
      then: exit
  - deny:
      set:
        outcome: DENIED
        reason: receipt is missing
      then: exit
  - manualReview:
      set:
        outcome: MANUAL_REVIEW
        reason: manual review required
      then: exit</code></code></pre><p>Run the minimal Runner image:</p><pre><code><code>podman run --rm \
  --name refunddesk-runner \
  -p 8081:8080 \
  -v "$PWD/workflows:/deployments/workflows:ro" \
  quay.io/quarkiverse/quarkus-flow-runner:0.11.0-minimal</code></code></pre><p>In another terminal, execute the workflow:</p><pre><code><code>curl -X POST 'http://localhost:8081/q/flow/exec/refunddesk/refund-policy/1.0.0?wait=true' \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 42.00,
    "receiptPresent": true,
    "chargebackCount": 0
  }'</code></code></pre><p>Expected output:</p><pre><code><code>{
  "instanceId": "01KW65RE8KKQPW7FT6CQY7QS5Y",
  "status": "COMPLETED",
  "startedAt": "2026-06-28T03:53:36.274777328Z",
  "completedAt": "2026-06-28T03:53:36.306582193Z",
  "workflowOutput": {
    "reason": "small refund with clean history",
    "outcome": "APPROVED"
  }
}</code></code></pre><p>This Runner version is intentionally smaller than the Java app. It shows the release feature without pretending YAML is always the right abstraction. The Runner can execute workflow definitions mounted at runtime. The Java app can call your CDI beans, keep policy code in normal Java, use tests, and participate in the rest of your Quarkus application.</p><p>That split is useful. Some workflows are platform configuration. Some workflows are application code. Treating both as the same thing usually makes one team unhappy.</p><h2><strong>Where </strong><code>@ScheduleOn</code><strong> Fits</strong></h2><p>Quarkus Flow 0.11.0 also adds <code>@ScheduleOn</code> for agentic LangChain4j workflows. That is not part of RefundDesk because this example does not need agents.</p><p>The feature matters when an agentic method should wake from an event, a cron schedule, or a fixed interval. For example, a nightly refund-anomaly reviewer could run every hour, fetch suspicious cases, and produce a report. The <a href="https://docs.quarkiverse.io/quarkus-flow/dev/schedule-agentic-workflows.html">Quarkus Flow </a><code>@ScheduleOn</code><a href="https://docs.quarkiverse.io/quarkus-flow/dev/schedule-agentic-workflows.html"> guide</a> documents three trigger styles:</p><ul><li><p><code>event</code> for a CloudEvent type</p></li><li><p><code>cron</code> for a Unix cron expression</p></li><li><p><code>every</code> for an ISO 8601 duration</p></li></ul><p>The same design rule applies: use the agent for the task that needs language or fuzzy reasoning. Use the workflow for the process that needs state, waiting, and evidence.</p><h2><strong>Production Notes That Matter</strong></h2><p>RefundDesk is small, but it creates real production questions.</p><p>Use a database for final decisions. The <code>ConcurrentHashMap</code> keeps the tutorial readable, but the business boundary belongs in storage with a unique key on <code>refundId</code>.</p><p>Use idempotency keys at receivers. If <code>recordManualReview()</code> became a real refund transaction, the downstream payment or ledger API should reject duplicate <code>refundId</code> operations or return the already-recorded result.</p><p>Keep workflow state outside the pod. MVStore is fine for local testing. Use Redis, JPA, or the production Runner variants when workflow state must survive node drains, pod replacement, and rolling updates.</p><p>Treat <code>flow-out</code> as part of the contract. The review UI should consume <code>refund.review.required</code>, store the <code>flowinstanceid</code>, and send it back on <code>refund.review.completed</code>. Guessing instance IDs from an HTTP response is a tutorial convenience.</p><p>Use structured logs or tracing before the first incident. Quarkus Flow can emit workflow and task lifecycle data. Searching by workflow instance ID is much better than stitching together five log lines by timestamp and hope.</p><p>Use Quartz for clustered schedules. The normal scheduler is enough for simple standalone deployments. The <a href="https://docs.quarkiverse.io/quarkus-flow/dev/quartz.html">Quarkus Flow Quartz scheduler docs</a> call out persistent Quartz for clustered scheduled workflows.</p><h2><strong>Conclusion</strong></h2><p>RefundDesk keeps the policy small and lets the workflow own the process around it: branching, event emission, waiting, correlation, and final recording. That is the mental model I would keep: rules decide from facts, workflows coordinate what happens over time.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Quarkus Data Hibernate: A New Path for Java Persistence]]></title><description><![CDATA[Build a small catalog API to see generated repositories, checked HQL, managed updates, stateless writes, paging, validation, and tests in one realistic Quarkus service.]]></description><link>https://www.the-main-thread.com/p/quarkus-data-hibernate-java-persistence</link><guid isPermaLink="false">https://www.the-main-thread.com/p/quarkus-data-hibernate-java-persistence</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Wed, 01 Jul 2026 06:09:01 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/c23cdfb2-8763-49b8-8731-78703b3f8506_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I have written about <a href="https://www.the-main-thread.com/p/quarkus-panache-next-jakarta-data-hibernate">Panache Next</a> before. The project continues to evolve and just recently changed the name to Quarkus Data. The name really wasn&#8217;t the most exciting part of the announcement anyway. That was the data access model behind it: repositories generated at build time, checked queries, managed and stateless Hibernate styles, and a path that can cover blocking and reactive code without making you rewrite the entity model.</p><p>With the new name <strong>Quarkus Data</strong>, future relational database work starts with the <code>quarkus-data-hibernate</code> extension. The <a href="https://quarkus.io/blog/introducing-quarkus-data/">Quarkus announcement</a> positions it as an umbrella for data access in Quarkus, built around the Jakarta Data direction and the Hibernate processor. I like the new name. &#8220;Panache&#8221; only meant something to you, if you&#8217;d already knew what it was all about. &#8220;Data&#8221; is what people search for when they are starting a service and need a database.</p><p>The API is still experimental in Quarkus 3.37.0, so keep that in mind. Names can still move. But the direction is clear enough to build with, test, and judge by behavior. The team is still looking for feedback about it. If you find something, make sure to report and let the team know.</p><p>We will build <strong>CatalogBoard</strong>, a small product catalog API. It is deliberately plain: products have a SKU, category, stock count, reorder point, and a discontinued flag. That gives us enough room to show generated finder methods, HQL queries checked at build time, managed updates, one explicit stateless update, pagination, validation, and tests.</p><p>When we are done, the API can create products, list them by category, search by name, flag low stock, adjust inventory, and hide discontinued products.</p><h2><strong>Prerequisites</strong></h2><p>You need a recent Quarkus setup and a container runtime for PostgreSQL Dev Services. The example uses Quarkus 3.37.0 and Java 21.</p><ul><li><p>Java 25</p></li><li><p>Quarkus CLI</p></li><li><p>Podman or another Testcontainers-compatible runtime</p></li><li><p>Basic Jakarta Persistence and REST knowledge</p></li><li><p>About three &#9749;&#65039;</p></li></ul><p>Quarkus Data Hibernate is experimental in this release. That works for a tutorial and for early project exploration. For a production service, put that status in the decision record so nobody has to rediscover it during an upgrade.</p><h2><strong>Create The Project</strong></h2><p>Create the app and follow along or <a href="https://github.com/myfear/the-main-thread/tree/main/catalog-board">grab the code from my repository</a>:</p><pre><code><code>quarkus create app io.mainthread.catalogboard:catalog-board \
  --extension=quarkus-data-hibernate,rest-jackson,jdbc-postgresql,hibernate-validator \
  --no-code
cd catalog-board</code></code></pre><p>Use these extensions:</p><ul><li><p><code>quarkus-data-hibernate</code> - the new Quarkus Data Hibernate entry point for relational data access</p></li><li><p><code>rest-jackson</code> - JSON REST endpoints</p></li><li><p><code>jdbc-postgresql</code> - PostgreSQL connectivity and Dev Services</p></li><li><p><code>hibernate-validator</code> - validation on request bodies and query parameters</p></li></ul><p>The generated project already has the platform-managed dependencies. Quarkus Data Hibernate also needs the Hibernate annotation processor so the repository implementations and query checks happen during compilation. Add this to the <code>maven-compiler-plugin</code> configuration in <code>pom.xml</code>:</p><pre><code><code>&lt;plugin&gt;
    &lt;artifactId&gt;maven-compiler-plugin&lt;/artifactId&gt;
    &lt;version&gt;${compiler-plugin.version}&lt;/version&gt;
    &lt;configuration&gt;
        &lt;parameters&gt;true&lt;/parameters&gt;
        &lt;annotationProcessorPathsUseDepMgmt&gt;true&lt;/annotationProcessorPathsUseDepMgmt&gt;
        &lt;annotationProcessorPaths&gt;
            &lt;path&gt;
                &lt;groupId&gt;org.hibernate.orm&lt;/groupId&gt;
                &lt;artifactId&gt;hibernate-processor&lt;/artifactId&gt;
            &lt;/path&gt;
        &lt;/annotationProcessorPaths&gt;
    &lt;/configuration&gt;
&lt;/plugin&gt;</code></code></pre><p>The <code>annotationProcessorPathsUseDepMgmt</code> flag lets the Quarkus BOM manage the processor version. Without the processor, the repository interfaces stay as interfaces. The generated implementation never appears.</p><h2><strong>Configure PostgreSQL</strong></h2><p>Add <code>src/main/resources/application.properties</code>:</p><pre><code><code>quarkus.datasource.db-kind=postgresql

%dev.quarkus.datasource.devservices.image-name=docker.io/library/postgres:17
%test.quarkus.datasource.devservices.image-name=docker.io/library/postgres:17

%dev.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%prod.quarkus.hibernate-orm.schema-management.strategy=validate

%prod.quarkus.datasource.username=${DB_USERNAME}
%prod.quarkus.datasource.password=${DB_PASSWORD}
%prod.quarkus.datasource.jdbc.url=${DB_JDBC_URL}

quarkus.hibernate-orm.log.sql=false</code></code></pre><p>Dev and test mode use PostgreSQL Dev Services. The schema is recreated on startup so the tutorial stays repeatable. Production uses <code>validate</code> because the application should not mutate real tables at startup. Use <a href="https://www.the-main-thread.com/p/quarkus-flyway-database-migrations-java">Flyway</a> or Liquibase there.</p><p>The <code>db-kind</code> property is the one setting we keep outside a profile. It tells Quarkus which datasource to prepare even before a real URL exists.</p><h2><strong>Add The Entity And Repositories</strong></h2><p>Create <code>src/main/java/io/mainthread/catalogboard/Product.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

import java.util.List;
import java.util.Optional;

import org.hibernate.annotations.processing.Find;
import org.hibernate.annotations.processing.HQL;

import io.quarkus.hibernate.panache.PanacheEntity;
import io.quarkus.hibernate.panache.PanacheRepository;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Entity
@Table(name = "products", uniqueConstraints = @UniqueConstraint(columnNames = "sku"))
public class Product extends PanacheEntity {

    @Column(nullable = false, length = 32)
    public String sku;

    @Column(nullable = false, length = 120)
    public String name;

    @Column(nullable = false, length = 80)
    public String category;

    @Column(nullable = false)
    public int stock;

    @Column(nullable = false)
    public int reorderPoint;

    @Column(nullable = false)
    public boolean discontinued;

    public boolean needsRestock() {
        return !discontinued &amp;&amp; stock &lt;= reorderPoint;
    }

    public interface Repo extends PanacheRepository&lt;Product&gt; {

        @Find
        Optional&lt;Product&gt; findBySku(String sku);

        @HQL("where category = :category and discontinued = false order by name")
        List&lt;Product&gt; findAvailableByCategory(String category);

        @HQL("where name like :pattern and discontinued = false order by name")
        List&lt;Product&gt; searchByName(String pattern);

        @HQL("where stock &lt;= reorderPoint and discontinued = false order by stock, sku")
        List&lt;Product&gt; findLowStock();

        @HQL("delete from Product where discontinued = true")
        long deleteDiscontinued();
    }

    public interface InventoryRepo extends PanacheRepository.Stateless&lt;Product, Long&gt; {

        @Find
        Optional&lt;Product&gt; findBySku(String sku);

        @HQL("update Product set stock = stock + :delta where sku = :sku and discontinued = false")
        long changeStock(String sku, int delta);
    }
}</code></code></pre><p>This file shows the core Quarkus Data model. The entity is ordinary Jakarta Persistence plus the Quarkus Data <code>PanacheEntity</code> base type. The nested repository interfaces are where the generated code appears.</p><p><code>@Find</code> derives a query from the method name and parameters. <code>findBySku(String sku)</code> becomes a query against the <code>sku</code> field. Returning <code>Optional&lt;Product&gt;</code> is intentional. If the method returns <code>Product</code>, absence becomes a <code>NoResultException</code>. For a REST lookup, <code>Optional</code> gives the resource a cleaner boundary.</p><p><code>@HQL</code> gives us explicit queries while still letting the Hibernate processor check the entity name, field names, syntax, and parameter names during compilation. Change <code>reorderPoint</code> to <code>reorderLevel</code> in the entity and forget to update the query, and the build catches it.</p><p>The second repository is stateless. Managed Hibernate tracks entity changes and flushes dirty state at transaction boundaries. A stateless repository does less tracking, so write operations stay explicit. <code>changeStock</code> is an update query because adjusting inventory is a command. That path should not depend on &#8220;load, mutate, hope somebody remembered the flush.&#8221;</p><p>Quarkus Data also supports Jakarta Data annotations such as <code>jakarta.data.repository.Find</code>, <code>Query</code>, and <code>Delete</code>. The <a href="https://quarkus.io/version/main/guides/quarkus-data-hibernate">current Quarkus Data Hibernate guide</a> shows both styles. I use the Hibernate annotations here because we are writing HQL and staying close to Hibernate ORM.</p><h2><strong>Add Request And Response Types</strong></h2><p>Keep JSON payloads away from the entity. The entity is the persistence model. The REST API can stay smaller.</p><p>Create <code>ProductCreateRequest.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ProductCreateRequest(
        @NotBlank @Size(max = 32) String sku,
        @NotBlank @Size(max = 120) String name,
        @NotBlank @Size(max = 80) String category,
        @Min(0) int stock,
        @Min(0) int reorderPoint) {
}</code></code></pre><p>Create <code>ProductUpdateRequest.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ProductUpdateRequest(
        @NotBlank @Size(max = 120) String name,
        @NotBlank @Size(max = 80) String category,
        @Min(0) int reorderPoint) {
}</code></code></pre><p>Create <code>StockAdjustment.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

import jakarta.validation.constraints.Min;

public record StockAdjustment(@Min(-1000) int delta) {
}</code></code></pre><p>Create <code>ProductResponse.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

public record ProductResponse(
        Long id,
        String sku,
        String name,
        String category,
        int stock,
        int reorderPoint,
        boolean discontinued,
        boolean needsRestock) {

    static ProductResponse from(Product product) {
        return new ProductResponse(
                product.id,
                product.sku,
                product.name,
                product.category,
                product.stock,
                product.reorderPoint,
                product.discontinued,
                product.needsRestock());
    }
}</code></code></pre><p>The create request includes <code>sku</code>; the update request does not. That prevents clients from changing the natural lookup key through a metadata update. You can pick a different boundary in your system, but it should be a deliberate choice.</p><h2><strong>Add The REST Resource</strong></h2><p>Create <code>src/main/java/io/mainthread/catalogboard/ProductResource.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

import java.net.URI;
import java.util.List;

import io.quarkus.hibernate.panache.blocking.PanacheBlockingQuery;
import jakarta.data.Order;
import jakarta.data.Sort;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {

    @Inject
    Product.Repo repo;

    @Inject
    Product.InventoryRepo inventoryRepo;

    @GET
    public List&lt;ProductResponse&gt; list(
            @QueryParam("category") String category,
            @QueryParam("page") @DefaultValue("0") @Min(0) int page,
            @QueryParam("size") @DefaultValue("20") @Min(1) @Max(100) int size) {
        PanacheBlockingQuery&lt;Product&gt; query = category == null || category.isBlank()
                ? repo.find("discontinued = false", Order.by(Sort.asc("name")))
                : repo.find("category = ?1 and discontinued = false", Order.by(Sort.asc("name")), category);

        return query.page(page, size)
                .list()
                .stream()
                .map(ProductResponse::from)
                .toList();
    }

    @GET
    @Path("/{sku}")
    public ProductResponse get(@PathParam("sku") String sku) {
        return ProductResponse.from(requireProduct(sku));
    }

    @GET
    @Path("/search")
    public List&lt;ProductResponse&gt; search(@QueryParam("q") @DefaultValue("") String query) {
        if (query.isBlank()) {
            return List.of();
        }
        return repo.searchByName("%" + query + "%")
                .stream()
                .map(ProductResponse::from)
                .toList();
    }

    @GET
    @Path("/low-stock")
    public List&lt;ProductResponse&gt; lowStock() {
        return repo.findLowStock()
                .stream()
                .map(ProductResponse::from)
                .toList();
    }

    @POST
    @Transactional
    public Response create(@Valid ProductCreateRequest request) {
        if (repo.findBySku(request.sku()).isPresent()) {
            throw new BadRequestException("SKU already exists: " + request.sku());
        }

        Product product = new Product();
        product.sku = request.sku();
        product.name = request.name();
        product.category = request.category();
        product.stock = request.stock();
        product.reorderPoint = request.reorderPoint();
        repo.persist(product);

        return Response.created(URI.create("/products/" + product.sku))
                .entity(ProductResponse.from(product))
                .build();
    }

    @PUT
    @Path("/{sku}")
    @Transactional
    public ProductResponse update(@PathParam("sku") String sku, @Valid ProductUpdateRequest request) {
        Product product = requireProduct(sku);
        product.name = request.name();
        product.category = request.category();
        product.reorderPoint = request.reorderPoint();
        return ProductResponse.from(product);
    }

    @PATCH
    @Path("/{sku}/stock")
    @Transactional
    public ProductResponse changeStock(@PathParam("sku") String sku, @Valid StockAdjustment adjustment) {
        long updated = inventoryRepo.changeStock(sku, adjustment.delta());
        if (updated == 0) {
            throw new NotFoundException("Active product not found: " + sku);
        }
        return ProductResponse.from(inventoryRepo.findBySku(sku)
                .orElseThrow(() -&gt; new NotFoundException("Product not found after stock change: " + sku)));
    }

    @DELETE
    @Path("/{sku}")
    @Transactional
    public Response discontinue(@PathParam("sku") String sku) {
        Product product = requireProduct(sku);
        product.discontinued = true;
        return Response.noContent().build();
    }

    @DELETE
    @Path("/maintenance/discontinued")
    @Transactional
    public Response deleteDiscontinued() {
        long deleted = repo.deleteDiscontinued();
        return Response.ok().entity(new DeletedProducts(deleted)).build();
    }

    private Product requireProduct(String sku) {
        Product product = repo.findBySku(sku)
                .orElseThrow(() -&gt; new NotFoundException("Product not found: " + sku));
        if (product.discontinued) {
            throw new NotFoundException("Product not found: " + sku);
        }
        return product;
    }

    public record DeletedProducts(long deleted) {
    }
}</code></code></pre><p>This class uses two different styles on purpose.</p><p>Create, update, and discontinue use the managed repository. Inside a transaction, Hibernate tracks the entity. When <code>update</code> changes <code>name</code>, <code>category</code>, and <code>reorderPoint</code>, there is no explicit <code>repo.update(product)</code> because the entity is already managed.</p><p><code>changeStock</code> uses the stateless repository. That method does not load the product first. It runs one update query and returns the updated row afterward. If the update count is zero, the product is missing or discontinued. That gives the endpoint a direct 404 path and keeps the command explicit.</p><p>The list endpoint uses <code>jakarta.data.Order</code> and <code>Sort</code> with Panache query paging. Quarkus Data also has REST integration for Jakarta Data types such as <code>PageRequest</code>, <code>Order</code>, <code>Sort</code>, and <code>Limit</code> when <code>quarkus-data-hibernate</code> and Quarkus REST are both present. The official guide shows a <code>PageRequest</code> and <code>Order&lt;T&gt;</code> endpoint shape for that case. Here I keep <code>page</code> and <code>size</code> as plain query parameters because I want the paging mechanics to stay visible.</p><h2><strong>Try The API</strong></h2><p>Start dev mode:</p><pre><code><code>./mvnw quarkus:dev</code></code></pre><p>Create a product:</p><pre><code><code>curl -s -X POST http://localhost:8080/products \
  -H 'Content-Type: application/json' \
  -d '{
    "sku": "SKU-100",
    "name": "Field Notebook",
    "category": "stationery",
    "stock": 8,
    "reorderPoint": 3
  }' | jq</code></code></pre><p>Expected shape:</p><pre><code><code>{
  "category": "stationery",
  "discontinued": false,
  "id": 1,
  "name": "Field Notebook",
  "needsRestock": false,
  "reorderPoint": 3,
  "sku": "SKU-100",
  "stock": 8
}</code></code></pre><p>Adjust stock:</p><pre><code><code>curl -s -X PATCH http://localhost:8080/products/SKU-100/stock \
  -H 'Content-Type: application/json' \
  -d '{"delta": -6}' | jq</code></code></pre><p>The product now needs restocking:</p><pre><code><code>{
  "sku": "SKU-100",
  "stock": 2,
  "reorderPoint": 3,
  "needsRestock": true
}</code></code></pre><p>The real response includes all fields. The shortened output above shows the part you want to check.</p><p>List low-stock products:</p><pre><code><code>curl -s http://localhost:8080/products/low-stock | jq</code></code></pre><p>Search by name:</p><pre><code><code>curl -s 'http://localhost:8080/products/search?q=Notebook' | jq</code></code></pre><p>Discontinue the product:</p><pre><code><code>curl -i -X DELETE http://localhost:8080/products/SKU-100</code></code></pre><p>After that, <code>GET /products/SKU-100</code> returns 404 because the API hides discontinued products from the active catalog.</p><h2><strong>Prove It With Tests</strong></h2><p>Create <code>src/test/java/io/mainthread/catalogboard/ProductResourceTest.java</code>:</p><pre><code><code>package io.mainthread.catalogboard;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;

@QuarkusTest
class ProductResourceTest {

    @Test
    void createsAndReadsProduct() {
        String sku = "SKU-" + System.nanoTime();

        given()
                .contentType(ContentType.JSON)
                .body("""
                        {
                          "sku": "%s",
                          "name": "Field Notebook",
                          "category": "stationery",
                          "stock": 8,
                          "reorderPoint": 3
                        }
                        """.formatted(sku))
                .when()
                .post("/products")
                .then()
                .statusCode(201)
                .body("sku", equalTo(sku))
                .body("needsRestock", equalTo(false));

        given()
                .when()
                .get("/products/{sku}", sku)
                .then()
                .statusCode(200)
                .body("name", equalTo("Field Notebook"))
                .body("category", equalTo("stationery"));
    }

    @Test
    void listsProductsByCategoryWithPagination() {
        String category = "category-" + System.nanoTime();
        createProduct("SKU-A-" + System.nanoTime(), "Alpha Binder", category, 4, 2);
        createProduct("SKU-B-" + System.nanoTime(), "Beta Binder", category, 4, 2);

        given()
                .queryParam("category", category)
                .queryParam("page", 0)
                .queryParam("size", 1)
                .when()
                .get("/products")
                .then()
                .statusCode(200)
                .body("", hasSize(1))
                .body("[0].name", equalTo("Alpha Binder"));
    }

    @Test
    void changesStockThroughStatelessRepositoryMethod() {
        String sku = "SKU-STOCK-" + System.nanoTime();
        createProduct(sku, "Shelf Label", "warehouse", 2, 5);

        given()
                .contentType(ContentType.JSON)
                .body("""
                        {
                          "delta": 4
                        }
                        """)
                .when()
                .patch("/products/{sku}/stock", sku)
                .then()
                .statusCode(200)
                .body("stock", equalTo(6))
                .body("needsRestock", equalTo(false));
    }

    @Test
    void findsLowStockProducts() {
        String sku = "SKU-LOW-" + System.nanoTime();
        createProduct(sku, "Packing Tape", "warehouse", 1, 5);

        given()
                .when()
                .get("/products/low-stock")
                .then()
                .statusCode(200)
                .body("sku", hasItem(sku))
                .body("findAll { it.sku == '%s' }.size()".formatted(sku), greaterThanOrEqualTo(1));
    }

    @Test
    void hidesDiscontinuedProductsFromCatalog() {
        String sku = "SKU-DISC-" + System.nanoTime();
        createProduct(sku, "Legacy Marker", "stationery", 4, 2);

        given()
                .when()
                .delete("/products/{sku}", sku)
                .then()
                .statusCode(204);

        given()
                .when()
                .get("/products/{sku}", sku)
                .then()
                .statusCode(404);

        given()
                .when()
                .get("/products/search?q=Legacy")
                .then()
                .statusCode(200)
                .body("sku", not(hasItem(sku)));
    }

    @Test
    void rejectsInvalidCreateRequest() {
        given()
                .contentType(ContentType.JSON)
                .body("""
                        {
                          "sku": "",
                          "name": "",
                          "category": "stationery",
                          "stock": -1,
                          "reorderPoint": 0
                        }
                        """)
                .when()
                .post("/products")
                .then()
                .statusCode(400);
    }

    private void createProduct(String sku, String name, String category, int stock, int reorderPoint) {
        given()
                .contentType(ContentType.JSON)
                .body("""
                        {
                          "sku": "%s",
                          "name": "%s",
                          "category": "%s",
                          "stock": %d,
                          "reorderPoint": %d
                        }
                        """.formatted(sku, name, category, stock, reorderPoint))
                .when()
                .post("/products")
                .then()
                .statusCode(201);
    }
}</code></code></pre><p>Run the tests:</p><pre><code><code>./mvnw test</code></code></pre><p>Expected result:</p><pre><code><code>Tests run: 6, Failures: 0, Errors: 0, Skipped: 0</code></code></pre><p>The tests check behavior, not just startup. Product creation goes through validation and persistence. Category listing proves sorting and paging. The stock test proves the stateless update method. The discontinued test proves the API hides soft-deleted products from reads and search.</p><p>Before you move on, make one deliberate mistake: change <code>reorderPoint</code> in <code>Product</code> to <code>reorderLevel</code> and compile. The <code>findLowStock</code> HQL query still points at <code>reorderPoint</code>, so the processor should fail the build. That is exactly what you want. A broken query should fail before the service boots.</p><h2><strong>What Changed From Classic Panache</strong></h2><p>Classic Panache is still available. The Quarkus team says Panache 1 is not going away. Existing services do not need a panic migration, which is good because teams rarely do their best architecture work in a panic.</p><p>For new code, Quarkus Data Hibernate is the direction of travel.</p><p>The important changes are:</p><ul><li><p><strong>A clearer extension name</strong> - <code>quarkus-data-hibernate</code> is the relational data entry point</p></li><li><p><strong>Generated repositories</strong> - interfaces are implemented at build time</p></li><li><p><strong>Checked queries</strong> - <code>@Find</code> and <code>@HQL</code> methods are validated against the entity model</p></li><li><p><strong>Managed and stateless choices</strong> - dirty checking when you want it, explicit writes when you do not</p></li><li><p><strong>Blocking and reactive variants</strong> - reactive support uses extra dependencies, but the model is designed to cover both</p></li><li><p><strong>Jakarta Data alignment</strong> - Quarkus Data can use Jakarta Data annotations and types where they fit</p></li><li><p><strong>REST integration</strong> - Jakarta Data paging and sorting types can be populated from HTTP query parameters</p></li></ul><p>This sample uses blocking Hibernate ORM because it is the path most Java readers should learn first. Reactive is there too, but that&#8217;s another article in the furture.</p><h2><strong>Production Notes</strong></h2><p>The extension is experimental in Quarkus 3.37.0. You should expect some API movement.</p><p>Do not carry <code>drop-and-create</code> into production. It deletes data on startup. In real environments, let Flyway or Liquibase own schema changes and keep Hibernate ORM on <code>validate</code>.</p><p>Keep transaction boundaries at the service or resource boundary. In this small app the resource is the boundary. In a larger service, I would move the write methods into an application service and keep the REST class thinner.</p><p>Be careful with repository security annotations. The current guide notes that annotations on a repository type do not secure inherited methods. Put security on the resource or service method that represents the real operation, then add repository method annotations only where they are directly declared and tested.</p><p>Finally, <code>@HQL</code> is not a license to build strings from user input. Use parameters, as <code>changeStock</code> does. The compiler can check your query shape. It cannot fix a bad authorization rule.</p><h2><strong>Conclusion</strong></h2><p>Quarkus Data Hibernate gives Quarkus a clearer database entry point and a better repository story: generated methods, checked queries, managed writes where they help, and explicit stateless operations where they are clearer. The name changed from Panache Next, but the important part is the build-time contract around the data layer.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Agent Skills Need Guardrails, Not Just Prompts]]></title><description><![CDATA[A practical guide to using skills, hooks, scripts, and QA gates so repeated agent workflows become controlled engineering work instead of better prompt snippets.]]></description><link>https://www.the-main-thread.com/p/agent-skills-automation</link><guid isPermaLink="false">https://www.the-main-thread.com/p/agent-skills-automation</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Tue, 30 Jun 2026 06:09:06 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ff3653d6-8e3d-455b-9518-8b07da3e17a5_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A weak skill looks like a better prompt with a file name.</p><p>It says &#8220;format this Markdown,&#8221; &#8220;review this PR,&#8221; or &#8220;write slides in our style,&#8221; and then leaves the agent to guess the rest from the conversation. That may work once. It may even look impressive if the task is simple. But it breaks the moment the workflow has local rules, fragile output, approvals, generated files, or a quality gate that must run before a human wastes time reading the result.</p><p>That is where skills become interesting.</p><p>I think of a skill as something stronger than a prompt library. A prompt library stores things you ask often. A skill stores how a repeated task should be done, where the agent should look, which scripts or tools make the work reliable, and which checks decide whether the result is ready.</p><p>That difference sounds small until you put it next to real developer work. A pull request summary is easy. A pull request summary that reads the right diff, respects protected paths, identifies test gaps, refuses to invent risk, and leaves the reviewer with a smaller decision is a workflow. A release note is easy. A release note that verifies tickets, changelog fragments, generated docs, and version bumps is a workflow. A slide deck is easy to ask for. A deck that uses the right template, stays inside text limits, runs brand checks, asks for approval before build, and fails QA on placeholder text is automation.</p><p>Skills are where that workflow knowledge lives.</p><h2><strong>The Small Definition</strong></h2><p>A skill is a reusable, named bundle of procedural knowledge for an agent.</p><p>Most systems I use have the same basic shape:</p><ul><li><p>a required instruction file, usually Markdown</p></li><li><p>metadata such as <code>name</code> and <code>description</code></p></li><li><p>optional reference files for deeper guidance</p></li><li><p>optional scripts for deterministic work</p></li><li><p>optional assets such as templates, images, schemas, examples, or sample outputs</p></li></ul><p>The <code>description</code> is the routing label. The agent sees many available skills, but it should not load all of them into every task. The description tells the agent when this skill applies. After that, the full body can give the workflow.</p><p>That gives skills their first practical property: progressive disclosure.</p><p>The agent does not need your whole release process when the user asks about a failing unit test. It needs the name and description of the release skill so it can decide that this is not the task. If the user does ask for release prep, the agent loads the full workflow. If that workflow points to <code>references/versioning.md</code>, <code>scripts/check-release.py</code>, or a release template, the agent reads or runs those only when needed.</p><p>That matters because context is not free. Even with large windows, stuffing every rule, reference, and example into the system prompt makes the agent worse at selection. It also makes maintenance painful. A good skill is loaded at the moment of use.</p><p>The mental model is simple:</p><pre><code><code>user request
  -&gt; skill metadata helps route the task
  -&gt; SKILL.md gives the workflow
  -&gt; references add deeper context only when needed
  -&gt; scripts and assets make fragile steps repeatable
  -&gt; hooks, tests, and CI decide whether the result is acceptable
</code></code></pre><p>The skill is one part of the system. It is the agent-facing contract for one kind of work.</p><h2><strong>How Skills Load</strong></h2><p>The exact mechanics vary by agent host, but the common pattern is stable.</p><p>First, the agent sees a lightweight catalog: skill names, descriptions, and sometimes locations or metadata. This catalog is enough for routing. It should stay small because it sits next to the user request, system instructions, project rules, and any other active context.</p><p>Then the agent decides whether a skill applies. A good description makes that decision cheap:</p><pre><code><code>description: Use when preparing a pull request summary from a branch diff,
including changed behavior, test evidence, risky files, and reviewer notes.
</code></code></pre><p>That is much better than:</p><pre><code><code>description: Helps with PRs.
</code></code></pre><p>The first version tells the agent when to load the skill. The second version only names a general topic.</p><p>After activation, the agent reads the full <code>SKILL.md</code>. This is where the workflow lives: steps, guardrails, stop conditions, verification, and links to supporting material.</p><p>References, scripts, and assets are the last layer. The skill should tell the agent when to read a reference, when to execute a script, and when to use an asset as output material. A reference file is context. A script is executable behavior. An asset is usually something the final artifact uses.</p><p>This loading model is why skill authors should keep the main file lean. Put the routing signal in the description. Put the core workflow in <code>SKILL.md</code>. Put long domain details, schemas, examples, and brand rules in references. Put deterministic work in scripts. Put templates and reusable media in assets.</p><h2><strong>What Belongs in a Skill</strong></h2><p>Put information in a skill when it is both reusable and task-specific.</p><p>That rules out a lot of noise.</p><p>Always-on repository policy belongs in <code>AGENTS.md</code>, project rules, or whatever your agent host uses for global instructions. &#8220;Use Podman for containers&#8221; and &#8220;do not commit secrets&#8221; are baseline rules. The agent should see them before any specific workflow starts.</p><p>A skill is narrower. It answers a question like:</p><ul><li><p>How do we prepare this repository&#8217;s pull requests?</p></li><li><p>How do we run a bounded refactor?</p></li><li><p>How do we publish a release note?</p></li><li><p>How do we generate this kind of customer deck?</p></li><li><p>How do we triage a dependency finding?</p></li><li><p>How do we review architecture boundaries?</p></li><li><p>How do we write and verify one type of documentation artifact?</p></li></ul><p>The scope should be small enough that the skill can be tested. If you cannot tell whether the skill improved behavior, it is probably too vague.</p><p>This is a decent skeleton:</p><pre><code><code>---
name: bounded-refactor
description: Use when making a small refactor that must preserve behavior, keep the diff narrow, and finish with tests plus a reviewer-facing summary.
---

Use this skill for refactors where the user names a specific class,
package, function, or behavior boundary.

Workflow:

1. Read the target code and the nearest tests before editing.
2. State the intended behavior boundary in one paragraph.
3. Make the smallest edit that achieves the requested change.
4. Run the narrowest relevant test first.
5. Run the broader test command only when the narrow test passes.
6. Summarize the diff as behavior preserved, implementation changed, and risk left.

Guardrails:

- Do not rename public APIs unless the user asked for that.
- Do not move files across module boundaries without calling it out first.
- Treat generated files as outputs, not source, unless this repo says otherwise.
- Stop if the first test failure is unrelated and explain the blocker.
</code></code></pre><p>This will not win a prose award. Good. It is a working contract.</p><p>The workflow tells the agent what to do. The guardrails tell it where to stop. The description tells it when to load the skill. That is already better than a prompt that says &#8220;please refactor carefully.&#8221;</p><h2><strong>The Automation Gradient</strong></h2><p>Skills start paying off when they connect instructions to deterministic pieces.</p><p>The gradient looks like this.</p><p><strong>Prompt</strong> - one-time instruction inside a chat. Good for exploration. Weak for repeatability.</p><p><strong>Command</strong> - a convenient entry point. Good for invocation. Often too small to hold the full workflow.</p><p><strong>Skill</strong> - reusable workflow with routing metadata, steps, references, guardrails, and expected output.</p><p><strong>Skill with scripts</strong> - the agent delegates fragile or repetitive operations to code. Good for parsing, validation, transformation, rendering, and checks.</p><p><strong>Skill with hooks</strong> - deterministic checks fire at known events, regardless of whether the agent remembered to be careful.</p><p><strong>Skill with CI gates</strong> - the repository repeats the load-bearing checks at the merge boundary.</p><p>That last part is where many teams stop too early. They write a <code>SKILL.md</code>, call it automation, and then ask the agent to remember the entire quality loop. Work with consequences needs a stronger loop.</p><p>If a task produces a deck, a report, generated source, release notes, an API client, or a migration patch, the skill should usually point to a script or a check. The agent is good at interpretation, selection, and repair. Counting characters in 40 slides by hand is a job for code.</p><p>For example, a deck-building skill should not say:</p><pre><code><code>Make sure the deck follows our brand.
</code></code></pre><p>It should say something closer to:</p><pre><code><code>1. Write the deck spec as JSON under `decks/&lt;name&gt;/&lt;name&gt;.json`.
2. Run `scripts/generate_outline.py --spec &lt;spec&gt; --fix`.
3. Resolve every `[OVER]` and `[LAYOUT]` warning before asking for approval.
4. After approval, run `scripts/build_from_template.py`.
5. Run `scripts/qa_deck.py`.
6. Do not call the deck ready until automatic QA passes and a visual review has happened.
</code></code></pre><p>Now the skill has moved past style advice. It describes a production path.</p><h2><strong>Skills and Hooks Solve Different Problems</strong></h2><p>Hooks are easy to confuse with skills because both shape agent behavior. They live at different layers.</p><p>A skill tells the agent how to do a task.</p><p>A hook runs when something happens.</p><p>The hook can be a Git hook, a pre-tool or post-tool hook in an agent host, a pre-commit scanner, a post-edit formatter, a pre-push policy check, or a CI job. The names vary by product. The pattern does not.</p><p>The part worth keeping is this: a hook should run even when the agent forgets the policy.</p><p>If a repository has a pre-commit hook that rejects secrets, the agent can be clever, tired, confused, or overconfident. The hook still runs. If it fails, the agent sees the output and can repair the diff. That is a much better loop than a sentence in a prompt that says &#8220;be careful with secrets.&#8221;</p><p>Skills and hooks work best when they share the same underlying checks.</p><pre><code><code>skills/release-prep/SKILL.md
  -&gt; tells the agent to update changelog fragments, bump versions, and run checks

scripts/check-release.py
  -&gt; validates changelog, versions, generated docs, and forbidden placeholders

.githooks/pre-commit
  -&gt; runs `scripts/check-release.py --staged`

.github/workflows/release-check.yml
  -&gt; runs `scripts/check-release.py --all`
</code></code></pre><p>The skill gives the path. The hook gives fast feedback. CI gives the merge boundary.</p><p>The same idea applies to code:</p><ul><li><p>The skill tells the agent how to make a bounded refactor.</p></li><li><p>The pre-commit hook rejects broad generated changes, secrets, formatting drift, or obvious policy violations.</p></li><li><p>The CI gate runs the full build, architecture tests, dependency policy, and security scanners.</p></li></ul><p>This is where agent automation starts to feel less like a chat trick. The agent is inside a control loop. It can still make mistakes, but the workflow gives it a way to see the mistake before a reviewer becomes the first real gate.</p><h2><strong>Guardrails Are Part of the Skill</strong></h2><p>Guardrails are stronger than &#8220;be careful&#8221; warnings. They are decisions about what the agent may do, what it must verify, and where human approval belongs.</p><p>A practical skill has at least a few explicit boundaries.</p><p><strong>Scope</strong> - Which tasks should activate this skill, and which should not?</p><p><strong>Inputs</strong> - Which files, commands, tickets, specs, or user-provided artifacts are the source of truth?</p><p><strong>Outputs</strong> - What should exist at the end: a diff, a JSON spec, a deck, a report, a test, a PR summary?</p><p><strong>Tool boundaries</strong> - Which tools are allowed or expected? Which tools are off limits for this workflow?</p><p><strong>Stop conditions</strong> - When should the agent pause and ask, or report a blocker instead of improvising?</p><p><strong>Approval points</strong> - Which step needs a human decision before generation, posting, publishing, or deployment continues?</p><p><strong>Verification</strong> - Which commands, scripts, tests, or QA checks decide whether the work is ready?</p><p>You can encode these as prose, but the more fragile the task is, the more you should move toward scripts and gates.</p><p>For a developer workflow, I like guardrails like these:</p><pre><code><code>Guardrails:

- Keep the diff inside the requested package unless the user approves a wider change.
- Treat changes to build files, CI workflows, security config, and suppression files as sensitive.
- Run the narrow test first. If it fails, fix that before running broader tests.
- Do not add broad suppressions such as `// nosemgrep`, `@SuppressWarnings`, or exclusion files without calling them out.
- If a generated file changes, run the generator again instead of editing the output by hand.
- If the tool that validates this workflow cannot run, report the workflow as blocked.
</code></code></pre><p>The last line matters. A failed check is different from &#8220;probably fine.&#8221; If the skill depends on a validator and the validator cannot run, the task did not pass. That may sound strict. It is also how you avoid turning QA into decoration.</p><h2><strong>QA Gates Make Skills Honest</strong></h2><p>Skills need their own QA.</p><p>That starts with the skill file itself. Check the front matter. Check links. Check reference paths. Check for placeholder text. Check that the description is specific enough to route. If the skill points to scripts, run those scripts. If the skill produces artifacts, inspect the artifacts.</p><p>Then test the behavior.</p><p>Do not only test the prompt that perfectly matches the skill. Test the near misses too.</p><ul><li><p>A request that should activate the skill</p></li><li><p>A request that sounds similar but should not activate it</p></li><li><p>A request with missing input</p></li><li><p>A request with conflicting constraints</p></li><li><p>A request that should stop for approval</p></li><li><p>A request where the validator fails</p></li></ul><p>This matters more for shared skills. A project skill used by one repository can evolve quickly. A global or team skill used across many repos becomes infrastructure. If it drifts, every user gets a slightly wrong assistant.</p><p>QA also needs output checks. A skill that generates Markdown can run a Markdown linter, link checker, or site build. A skill that changes Java can run focused tests, architecture checks, formatters, and dependency policy. A skill that creates slide decks can run text-limit checks, placeholder checks, brand checks, and a visual review step.</p><p>The skill should make these checks part of normal work. The hook or CI gate should make the high-risk ones hard to skip.</p><h2><strong>Developer Workflows That Deserve Skills</strong></h2><p>The best developer skills are boring in a good way. They encode the work that happens often, has local rules, and wastes time when done wrong.</p><p><strong>Pull request preparation</strong> - Read the branch diff against the right base, summarize behavior changes, call out tests, list risky files, and propose a title. This is a good beginner skill because the source of truth is local and the output is easy to review.</p><p><strong>Bounded refactor</strong> - Keep the change inside a named scope, preserve behavior, run tests in order, and report what moved. This is much better than asking an agent to &#8220;clean up&#8221; code.</p><p><strong>Test gap analysis</strong> - Read a change, inspect nearby tests, identify missing assertions, and add only the tests that match the behavior. Agents often write tests that prove the implementation instead of the contract, so the skill needs to keep the contract visible.</p><p><strong>Dependency and security triage</strong> - Pull scanner output, map findings to direct or transitive dependencies, check whether the vulnerable code path is reachable, and recommend patch, suppress, or accept-with-owner. The skill should not decide risk alone, but it can make the review smaller.</p><p><strong>Architecture boundary review</strong> - Inspect package/module dependencies and turn stable findings into tests, for example with architecture rules. The skill should separate confirmed violations from suspicious edges that need human judgment.</p><p><strong>Framework migration slice</strong> - Run a dry-run migration tool, inspect the patch, apply one slice, run tests, and stop before the diff becomes a rewrite of the whole system.</p><p><strong>Release prep</strong> - Update changelog, versions, generated docs, release notes, and checks. This almost always deserves scripts because version text and generated files drift easily.</p><p><strong>Documentation publishing</strong> - Read the canonical source, generate social copy, check links, verify image assets, and keep the copy inside platform limits. The work is simple, but it is easy to make one small wrong post at 8:08 in the morning and then spend the day looking at it.</p><p><strong>Incident or runbook support</strong> - Gather logs, current status, recent deploys, and known rollback steps. The guardrail needs teeth here: the skill should distinguish read-only diagnosis from commands that change production.</p><p>These are plain skills. They are also exactly where teams lose time. Good automation usually starts with repeated annoyance, not with a grand architecture.</p><h2><strong>What Should Stay Out</strong></h2><p>Some things belong somewhere else.</p><p>A one-off answer does not need a skill. If the workflow is only needed once, write the prompt and move on.</p><p>Broad personality or tone preferences should usually live in always-on instructions or editing skills with a very clear scope. Do not hide repo-wide writing style inside a release skill.</p><p>Secrets belong outside skills. A skill can say where to find a secret through the approved mechanism. It should not contain the secret.</p><p>Credentials, deployment tokens, production URLs, and customer data need external controls. A skill can describe the safe path. It should not become a private vault with Markdown syntax highlighting.</p><p>Large reference manuals should not be pasted into <code>SKILL.md</code>. Put them in reference files and tell the agent when to read them. If a reference is huge, include search hints. The agent should not read 80 pages of API docs because one small task needed the JSON schema.</p><p>Finally, avoid all-purpose skills. &#8220;Use when coding in this repository&#8221; is a project instruction. A skill that always applies has become background policy.</p><h2><strong>The Special Cases</strong></h2><p>Skills get interesting around the edges.</p><h3><strong>Project, global, and team skills</strong></h3><p>A project skill lives with one repository. This is the default I prefer for developer workflows because the skill is reviewed with the code it affects. The team pulls the repo and gets the same workflow.</p><p>A global skill lives on one developer&#8217;s machine and follows them across workspaces. That is good for personal routines: standup notes, personal writing checks, local review style, or small helper workflows.</p><p>A team skill repository or packaged skill artifact makes sense when the same workflow should apply to many repos. At that point, versioning matters. You want release notes, compatibility notes, and a way to roll out changes without quietly changing every agent behavior overnight.</p><p>The sharing model changes ownership. A project skill belongs to that repo. A team skill belongs to the platform or enablement group that ships it.</p><h3><strong>Permissions and execution</strong></h3><p>Many agent hosts separate instruction loading from tool permission. A skill may tell the agent which command to run, but the shell, filesystem, browser, external API, or posting action may still need explicit approval.</p><p>That is a good boundary. The skill should name the commands and external calls it expects, and it should say what happens when the host refuses them.</p><p>For example:</p><pre><code><code>This workflow needs read access to `publishing/&lt;slug&gt;/publishing-pack.md`,
execute access for `scripts/check-social-copy.py`, and Buffer access for
scheduling. If any of those permissions are missing, stop and report which
step is blocked.
</code></code></pre><p>The skill explains the workflow. The host still decides what the agent may do in this task.</p><h3><strong>Skill collisions</strong></h3><p>If two skills have similar names or descriptions, routing gets soft. The agent may load the wrong one or load both and mix the instructions into a bad compromise.</p><p>Make descriptions discriminating. &#8220;Use for release work&#8221; is weak. &#8220;Use when preparing a Java service release from changelog fragments, version files, and generated API docs&#8221; is better.</p><p>Collisions are also a reason to keep project-specific rules near the project. A repo-specific skill should usually win over a global habit.</p><h3><strong>Skills with assets</strong></h3><p>Assets change the task from &#8220;write text&#8221; to &#8220;produce an artifact from a known base.&#8221;</p><p>Templates, slide masters, icon catalogs, image masks, OpenAPI examples, Terraform module snippets, legal boilerplate, or approved diagrams all belong here when the output must match a concrete format.</p><p>The skill should tell the agent whether to read the asset, copy it, transform it, or pass it to a script. File names are too weak as workflow instructions.</p><h3><strong>Skills with scripts</strong></h3><p>Scripts are where you move work that should be independent of language-model memory.</p><p>Use scripts for:</p><ul><li><p>parsing structured files</p></li><li><p>rendering or generating artifacts</p></li><li><p>counting text limits</p></li><li><p>validating schemas</p></li><li><p>checking links</p></li><li><p>comparing generated output with source</p></li><li><p>extracting project facts</p></li><li><p>running repeatable transformations</p></li></ul><p>The skill should explain how to run the script, what output matters, and which failures are blockers. The script should do the deterministic part. The agent should interpret, fix, and explain.</p><h3><strong>Multiple skills in one task</strong></h3><p>Sometimes one task really needs more than one skill. A publishing workflow may need an article voice skill, a publishing-pack skill, and a social-scheduling skill. That is fine if each skill owns a clear phase.</p><p>The danger is instruction conflict. One skill says &#8220;rewrite copy only when asked.&#8221; Another says &#8220;always improve copy before scheduling.&#8221; That conflict will show up as surprising behavior.</p><p>The better fix is clear ownership:</p><ul><li><p>voice skill changes prose</p></li><li><p>publishing-pack skill creates assets</p></li><li><p>scheduling skill posts exact approved copy</p></li></ul><p>The scheduling skill should explicitly say that it posts approved copy and only rewrites when the user asks. That small line can save a lot of accidental creativity.</p><h3><strong>Human approval in the middle</strong></h3><p>Some skills should pause.</p><p>Decks, release posts, external publishing, dependency suppressions, migrations, and destructive operations all need approval points. The skill should name those points directly.</p><p>For example:</p><pre><code><code>After generating the outline, stop and ask for approval before building the final artifact.
After finding dependency suppressions, stop and list each suppression before editing policy files.
Before posting to external channels, verify the target account and show the exact copy.
</code></code></pre><p>This is still automation. Good automation has brakes.</p><h2><strong>The Difference Between a Skill and a Tool</strong></h2><p>An MCP tool, CLI command, or local script does work.</p><p>A skill tells the agent how and when to use that work.</p><p>This distinction matters because teams often wire many tools into an agent and then wonder why behavior gets worse. A tool catalog gives the agent capability. The workflow still needs judgment.</p><p>If the agent has 50 tools and no skill, it must infer the workflow from tool names and conversation context. That works for simple calls. It fails when the right path is &#8220;read the spec, validate the account, check duplicates, ask for approval, then call the posting tool.&#8221;</p><p>The skill carries that order.</p><p>The tool should be narrow and honest. The skill should provide the procedure around it. Hooks and CI should verify the parts that need enforcement.</p><h2><strong>A Practical Skill Design Test</strong></h2><p>When I review a skill, I ask a few simple questions.</p><p>Can the description decide routing without reading the body?</p><p>Can a teammate understand the workflow in two minutes?</p><p>Does the skill say where the source of truth lives?</p><p>Does it tell the agent where to stop?</p><p>Does it point to scripts for work that should be deterministic?</p><p>Does it say how to verify the result?</p><p>Can we test activation, rejection, failure, and output quality?</p><p>Does it reduce human review work, or does it only produce nicer-sounding output?</p><p>That last question carries the weight. Skills should reduce wrong turns, not make the agent more theatrical.</p><h2><strong>A Small Example With Hooks</strong></h2><p>Imagine a team has a recurring workflow: update generated API docs after OpenAPI changes.</p><p>A weak instruction says:</p><pre><code><code>Remember to update the API docs after changing OpenAPI.
</code></code></pre><p>The skill version gives the agent a path:</p><pre><code><code>---
name: api-doc-refresh
description: Use when OpenAPI files, REST resources, or generated API documentation change.
---

Workflow:

1. Detect changed OpenAPI source files and REST resource files.
2. Run `scripts/generate-api-docs.sh`.
3. Run `scripts/check-api-docs.sh`.
4. If generated docs changed, include them in the diff.
5. If the check fails, fix the source or generator input. Do not hand-edit generated docs.

Guardrails:

- Treat generated documentation as output.
- Do not edit files under `docs/api/generated/` by hand.
- If the generator is missing or fails, report the task as blocked.
</code></code></pre><p>Then the repository adds a hook:</p><pre><code><code>#!/usr/bin/env bash
set -euo pipefail

scripts/check-api-docs.sh --staged
</code></code></pre><p>And CI runs:</p><pre><code><code>scripts/generate-api-docs.sh
scripts/check-api-docs.sh --all
</code></code></pre><p>Now the workflow has three layers. The skill guides the agent. The hook catches local drift. CI protects the merge boundary.</p><p>The agent can still get it wrong. But it gets a clear error, a script name, and a repair path. Agents handle that mistake loop well.</p><h2><strong>Skills Are a Maintenance Surface</strong></h2><p>Once a skill changes generated code, publishes artifacts, schedules posts, edits release files, or shapes review output, it is part of your engineering system.</p><p>Treat it that way.</p><p>Review skill changes. Lint skill files. Test them with realistic prompts. Version shared skills. Keep references current. Delete stale examples. Pin scripts and tool versions where drift would hurt. Put skill-owned checks into hooks or CI when the output matters.</p><p>Also measure behavior, not vibes. Did the skill reduce retry turns? Did it keep the diff smaller? Did it catch missing tests? Did it stop posting to the wrong channel? Did reviewers spend less time reconstructing intent? Did bad outputs fail earlier?</p><p>That is a better discussion than &#8220;the agent sounded more confident.&#8221;</p><p>Confidence is cheap. A smaller, verified workflow is better.</p><h2><strong>Where I Would Start</strong></h2><p>For a software team starting from zero, I would skip the giant skill library.</p><p>Start with one repeated task that already annoys people and has a clear quality gate.</p><p>Good first choices:</p><ul><li><p>PR preparation for one repository</p></li><li><p>bounded refactor for one module</p></li><li><p>release-note generation with changelog checks</p></li><li><p>dependency finding triage with scanner output</p></li><li><p>documentation publishing with link checks</p></li><li><p>generated API docs refresh</p></li></ul><p>Write the smallest skill that makes the task repeatable. Add one script if the task needs deterministic validation. Add a hook if the mistake is cheap to catch before commit. Add CI if the mistake must not land.</p><p>Then run it on real work. The first version will be incomplete. That is fine. The failures worth keeping will tell you what the skill forgot.</p><p>This is the part I like about skills. A bad prompt just disappoints you. A bad skill can be improved as an artifact. You can review it, patch it, test it, and ship the next version.</p><h2><strong>The Actual Point</strong></h2><p>Agent skills are ordinary files with unusual leverage. They do not replace tests, hooks, CI, or human judgment. They put repeatable workflow knowledge where the agent can load it at the right moment.</p><p>Used well, they turn &#8220;please be careful&#8221; into a path:</p><pre><code><code>read the right source
make the narrow change
run the deterministic check
respect the approval point
fail closed when validation fails
report the result in the shape humans need
</code></code></pre><p>That is the automation worth caring about.</p><p>The beginner mistake is to make a skill that tells the agent what output should look like. The better version tells the agent how the work moves through the system, which tools own which facts, and what must be true before the result is ready.</p><p>At that point the Markdown file is no longer just Markdown. It is a small contract between the human, the agent, and the repository.</p>]]></content:encoded></item><item><title><![CDATA[Why Shorter Prompts Can Make Coding Agents Worse]]></title><description><![CDATA[Catalog tax and payload tax are selection problems; compressing the task brief that still carries meaning is where coding-agent cost and quality both start getting worse.]]></description><link>https://www.the-main-thread.com/p/token-minimization-context-budget</link><guid isPermaLink="false">https://www.the-main-thread.com/p/token-minimization-context-budget</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Mon, 29 Jun 2026 06:08:02 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/45d74fab-4a76-4842-beff-cd99ba96d98d_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The moment an AI coding session gets expensive, someone opens the token counter.</p><p>Then the cutting starts. System prompt. Tool descriptions. Old examples. Comments. Variable names in injected snippets. Sometimes even the task brief gets squeezed until it reads like somebody was charged by the character.</p><p>I understand the reflex. Context is not free. In <a href="https://www.the-main-thread.com/p/bob-mcp-context-tax">The GitHub MCP Server Can Burn 17k Tokens Before You Ask a Real Question</a>, I measured one obvious leak: wide tool catalogs. A large MCP surface can burn a noticeable part of the window before the agent reads a single file. The second leak is payload. Raw diffs, full documents, and replayed transcripts can eat the same budget a minute later.</p><p>Those two cuts help a lot. Narrow the tool surface. Narrow the payload.</p><p>The trouble starts after that. Teams keep cutting, but the next cuts land on the wrong material.</p><h2><strong>The Expensive Mistake Starts After the Useful Cuts</strong></h2><p>Once the broad tool catalog is gone and the raw payload is under control, what remains is usually the part that carries meaning: the task description, the few-shot examples, the names in the code, the explanation of local rules, the one weird business constraint nobody would guess from reading the repository.</p><p>That is the part people start compressing.</p><p>A recent paper, <em><a href="https://arxiv.org/abs/2604.07502">Beyond Human-Readable: Rethinking Software Engineering Conventions for the Agentic Development Era</a></em>, gives this mistake a methodology. The paper argues for <strong>semantic density</strong>, which means how much useful meaning each token carries. In its log-format experiment, aggressive compression reduced input tokens by 17 percent and still increased total session cost by 67 percent. The model had less text to read and more work to do.</p><p>That result makes sense to me. If you replace clear names with abbreviations, strip context until it becomes ambiguous, or turn a precise task brief into terse prompt folklore, the work does not disappear. The model still has to recover the missing meaning. Now it pays for that recovery with extra reasoning, extra retries, and extra correction loops.</p><p><a href="https://developers.openai.com/api/docs/guides/reasoning">OpenAI&#8217;s reasoning guide</a> says this pretty clear: reasoning tokens still use context-window space and are billed as output. So when a compressed prompt forces the model to reconstruct what you used to state clearly, the bill moved. It did not shrink.</p><p>I think of that as the compression tax. You saved on the visible input side and paid the difference somewhere harder to see.</p><h2><strong>This Is the Same Accounting Bug Again</strong></h2><p>Thiskeeps coming back to the same pattern over and over. We measure the easy thing and ignore the expensive thing.</p><p>In <a href="https://www.the-main-thread.com/p/burning-ai-coins">Stop Measuring AI by How Much You Use It</a>, I argued that token burn is heat. It is measurable activity. It is not proof of useful work. In <a href="https://www.the-main-thread.com/p/ai-coding-break-even">AI Coding Break-Even: Cheap Tokens, Expensive Software</a>, I made the same point at delivery scale: the real cost shows up in review, repair, security, rollout, and the hours after the model claims success. In <a href="https://www.the-main-thread.com/p/spec-trap-agent-work">The Spec Trap</a>, the cheap-looking move was underspecifying the job and pushing the ambiguity downstream.</p><p><strong>Token minimization makes the same mistake at prompt scale.</strong></p><p>A smaller prompt can be cheaper. A smaller ambiguous prompt often is not. It can produce longer output, more retries, worse tool choices, and more human cleanup. The dashboard still shows fewer input tokens, which is nice if your goal is to win an argument in a screenshot. The real system pays elsewhere.</p><h2><strong>Vendors Already Know This</strong></h2><p>One reason I do not take &#8220;just make the prompt shorter&#8221; very seriously is that the platform vendors keep shipping features that solve a different problem.</p><p><a href="https://developers.openai.com/api/docs/guides/prompt-caching">OpenAI&#8217;s prompt caching guide</a> tells you to keep repeated content in a stable prefix so cached prefixes can cut cost and latency. <a href="https://ai.google.dev/gemini-api/docs/caching">Gemini&#8217;s context caching docs</a> split the same idea into implicit and explicit caching. <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/manage-tool-context">Anthropic&#8217;s guide to managing tool context</a> says large toolsets should use tool search so the full catalog stays out of the window until the model asks for it. <a href="https://platform.claude.com/docs/en/build-with-claude/context-editing">Anthropic&#8217;s context editing docs</a> says: context is finite, returns diminish, and irrelevant content degrades model focus.</p><p>Even if they are all burning through a lot of experimentation money right now, the fact that they all have this as tool category is a solid hint that this did not happen by accident.</p><p>Repeated context should be cached. Large tool catalogs should be loaded late. Stale results should be trimmed. Old conversation debris should stop competing with the current task. The pattern is clear enough: cut repetition, cut stale context, cut broad catalogs, and keep the remaining signal readable.</p><h2><strong>The Target Is Semantic Density</strong></h2><p>Aim for the densest prompt you can build without cutting away meaning.</p><p>That changes the operating rule.</p><p>A wide tool catalog is low density because most of the tools are irrelevant on this turn. A full raw diff is low density when the agent only needs the touched files that affect one behavior. A replayed transcript is low density when three earlier summaries and two failed attempts are still sitting in the window long after they stopped helping.</p><p>Clear task instructions, good names, and the local rule that explains a weird edge case all earn their place. Those tokens pull their weight.</p><p>The point is simple: remove noise first. Preserve signal. If you keep cutting after that, you start deleting the map and asking the model to redraw it from memory.</p><p>The split is easier to see when you draw the window as layers instead of one big token number.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!UagF!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!UagF!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 424w, https://substackcdn.com/image/fetch/$s_!UagF!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 848w, https://substackcdn.com/image/fetch/$s_!UagF!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 1272w, https://substackcdn.com/image/fetch/$s_!UagF!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!UagF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png" width="1456" height="934" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:934,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:260476,&quot;alt&quot;:&quot;Two cuts are selection work. The last cut is where teams start deleting meaning instead of noise.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://www.the-main-thread.com/i/202426623?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Two cuts are selection work. The last cut is where teams start deleting meaning instead of noise." title="Two cuts are selection work. The last cut is where teams start deleting meaning instead of noise." srcset="https://substackcdn.com/image/fetch/$s_!UagF!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 424w, https://substackcdn.com/image/fetch/$s_!UagF!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 848w, https://substackcdn.com/image/fetch/$s_!UagF!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 1272w, https://substackcdn.com/image/fetch/$s_!UagF!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F94655e7c-751b-48d4-ba4e-0afd5ccd7586_1740x1116.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><em>Two cuts are selection work. The last cut is where teams start deleting meaning instead of noise.</em></p><h2><strong>Caveman Is a Useful Counterexample</strong></h2><p>This is also why <a href="https://github.com/juliusbrussee/caveman">juliusbrussee/caveman</a> is interesting and not a contradiction.</p><p>The project is not mainly trying to compress the part I want to protect. It is mostly compressing the agent&#8217;s speaking style. The README claims about 65 percent average output reduction across ten prompts, and it is explicit about the boundary: output gets smaller, reasoning tokens do not. That lines up with the difference this article is trying to draw.</p><p>If a model wastes tokens on filler, politeness, repeated restatement, and long glue text, shorter output can help both budget and quality. The answer gets faster to read. Less narration means fewer places for the important instructions to hide. That is a different move from stripping context out of the task brief until the model has to guess what you meant.</p><p><code>caveman</code> also pushes on the input side in narrower ways. The repo includes <code>caveman-compress</code> for memory files and a middleware layer that shrinks MCP tool descriptions. That can be a good trade if the compression preserves the load-bearing content. The project README says code, URLs, paths, and error strings stay exact. That is the only version of compression I think makes sense: cut the wrapper, keep the meaning.</p><p>So I would use <code>caveman</code> as a qualifier. Compression helps when it removes low-value phrasing around stable meaning. Compression hurts when it removes the meaning itself. Same word, different target, very different outcome.</p><h2><strong>What I Would Do Instead</strong></h2><p>If I had to reduce this to a small operating rule, it would look like this:</p><ol><li><p><strong>Cache what repeats.</strong> Stable instructions, examples, and other shared prefixes should not be resent at full price on every turn.</p></li><li><p><strong>Retrieve narrow evidence.</strong> Give the model the sections, files, or snippets that answer the current question. Keep the archive available, not injected by default.</p></li><li><p><strong>Load tools on demand and trim old tool chatter.</strong> Large tool menus and stale tool results are both taxes.</p></li><li><p><strong>Keep the remaining context clear.</strong> Write the task brief plainly. Keep real names. State the local constraints in full sentences. Ask for a bounded artifact when a decision object or structured result is enough.</p></li></ol><p>That is token discipline.</p><h2><strong>Clear Text Still Wins</strong></h2><p>Some teams still get stuck. They see a long prompt and assume the length itself is the problem. Sometimes it is. Often the real problem is that the prompt is carrying the wrong material. I am not saying that we should resurface the prompt engineering hype. But also not forget about what good prompts look like.</p><p>A clear system prompt with stable rules can be cached. A clear task brief can prevent three correction loops. A clear tool description can stop a bad call before it happens. A clear variable or field name can save the model from inventing a meaning that was never there. </p><p>One line to remember:</p><blockquote><p><strong>Cut noise hard. Keep meaning intact.</strong></p></blockquote><p>That is a better default than &#8220;make the prompt shorter&#8221; because it matches how the bill really moves through an agent system. The catalog tax is real. The payload tax is real. The compression tax is real too, and it is the one teams keep charging themselves.</p><p>The model is rarely confused because you used full words. It gets lost when the window is full of stale junk and the remaining signal arrives half-compressed. That is a context problem. It needs selection, structure, and a little less obsession with the screenshot-friendly part of the meter.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[How to Find Regression Risks in a Quarkus App Before You Write the Feature]]></title><description><![CDATA[Use IBM Bob to inspect a real Quarkus codebase, spot the dangerous paths around user deletion, and turn that analysis into a clearer test plan.]]></description><link>https://www.the-main-thread.com/p/regression-testing-bob</link><guid isPermaLink="false">https://www.the-main-thread.com/p/regression-testing-bob</guid><dc:creator><![CDATA[Markus Eisele]]></dc:creator><pubDate>Sun, 28 Jun 2026 06:08:30 GMT</pubDate><enclosure url="https://api.substack.com/feed/podcast/202229400/0420699a6d274c55480a85cbd0432146.mp3" length="0" type="audio/mpeg"/><content:encoded><![CDATA[<p>User unregistration sounds simple until you look at the rest of the system.</p><p>In a booking application, &#8220;delete this user&#8221; is never just one <code>DELETE</code> endpoint. It touches active bookings, foreign keys, historical reads, cache state, and the uncomfortable question every senior team eventually has to answer: is this really a hard delete, or is it deactivation, anonymization, or something in between?</p><p>That is why I like the workflow Alex Soto shows in the latest video. He does not start by asking IBM Bob to write the feature. He starts by asking it to map the regression impact before implementation. That is a much better use of an assistant.</p><h2><strong>Use the Agent Before the Feature Code</strong></h2><p>I still think the basic rule is simple: use AI to go faster on work you understand already. If you hand core business logic to a model and hope for the best, you are outsourcing the part you still need to own.</p><p>Impact analysis is different. Here the agent helps you read the shape of the system, collect likely break points, and build a regression plan before the feature branch gets noisy. In this case, &#8220;shift-left&#8221; just means you do the regression thinking before you touch the code that will trigger it.</p><p>That matters because deletion flows are where business rules stop looking like CRUD. One missing constraint check and you get orphaned rows, broken history screens, or a second-order production issue that only shows up after cleanup jobs or cache refreshes run. Efficient. Also bad.</p><h2><strong>Why IBM Bob Fits This Job</strong></h2><p>It&#8217;s not IBM Bob&#8217;s magic that makes this work: It is context.</p><p>Bob runs against the workspace, so it can inspect the actual resources, entities, and services in the application. That gives it a real basis for the regression plan. It is reading the model you are about to change, not guessing from a paragraph of pasted code.</p><p>That is the difference. A browser chat can still help, but a workspace-aware tool can follow relationships across the codebase. For something like user unregistration, that is where the interesting bugs live.</p><h2><strong>The Prompt Is Small on Purpose</strong></h2><p>Alex starts with a constrained prompt:</p><blockquote><p><em>&#8220;Act as a tester. Identify the areas of the system that should be included in regression testing after changes are made to add the unregistration of a user. Don&#8217;t implement anything.&#8221;</em></p></blockquote><p><code>Don't implement anything</code> keeps the session focused on analysis. Once an assistant starts writing code too early, it tends to collapse design, impact analysis, and implementation into one optimistic blob. That is fine for demos. It is much less fine when a deletion rule can invalidate half your booking flow.</p><h2><strong>What a Good Regression Plan Should Surface</strong></h2><p>Because Bob can inspect the workspace, it can point at actual system boundaries that deserve tests before the first delete path is implemented. In this example, the report highlights the kind of things I would expect a senior developer to check immediately:</p><ul><li><p><strong>Active booking rules.</strong> What happens if the user still has a live reservation, an open payment flow, or another stateful process attached to the account?</p></li><li><p><strong>Database integrity.</strong> Do deletes cascade, fail fast, orphan records, or silently leave history in a shape the rest of the system no longer understands?</p></li><li><p><strong>Historical read paths.</strong> Can past bookings, reports, and audit views still load after the user record changes or disappears?</p></li><li><p><strong>Cache and derived state.</strong> If user data is cached, indexed, or copied into another read model, what gets invalidated and when?</p></li><li><p><strong>API behavior.</strong> What does the endpoint return when the user has already been unregistered, cannot be removed, or does not exist anymore?</p></li></ul><p>This is where the workflow earns its keep. The assistant is not replacing engineering judgment. It is helping you build the list you will review anyway, only faster and with fewer blind spots.</p><h2><strong>The Senior Java Questions Start After the First Report</strong></h2><p>A regression plan is a good start. It is still only a start.</p><p>If I were taking this feature forward, I would want the team to answer a few more questions before implementation:</p><ul><li><p><strong>Is &#8220;unregister&#8221; really delete?</strong> In many systems the right answer is soft delete, anonymization, or status change because legal retention, reconciliation, or audit rules still apply.</p></li><li><p><strong>Where is the transaction boundary?</strong> If one part of the removal fails, what rolls back, and what can be left half-done?</p></li><li><p><strong>What is the concurrency story?</strong> If one request unregisters the user while another creates or confirms a booking, which invariant wins?</p></li><li><p><strong>What happens on the second call?</strong> Idempotence matters here. A repeated delete should not create fresh damage.</p></li><li><p><strong>Which tests belong at which level?</strong> Some checks belong close to the domain model. Others need <code>@QuarkusTest</code>, HTTP assertions, persistence wiring, or a packaged-app check with <code>@QuarkusIntegrationTest</code>.</p></li></ul><p>That last point is worth calling out. A lot of teams mix regression planning with test implementation planning and then get a giant bag of tests with no clear boundary. I would rather make that mapping explicit up front.</p><h2><strong>Turn the Report Into Proof</strong></h2><p>The report is useful because it changes what we test first.</p><p>For a change like this, I would usually split the work into a few layers:</p><ul><li><p>business-rule tests around the unregister decision itself</p></li><li><p>persistence tests for relationship handling and transaction rollback</p></li><li><p><code>@QuarkusTest</code> coverage for the HTTP and CDI wiring</p></li><li><p>packaged-app checks with <code>@QuarkusIntegrationTest</code> when the deployment shape matters</p></li></ul><p>After that, continuous feedback matters more than one big test run at the end. Quarkus continuous testing is a good fit here because the regression plan will usually touch multiple paths, and you want quick confirmation while the model is still moving.</p><p>Coverage still matters, but later. First make sure the dangerous branches are tested. Then measure what you actually covered.</p><h2><strong>Further Reading</strong></h2><ul><li><p><a href="https://quarkus.io/guides/getting-started-testing">Quarkus: Testing Your Application</a> for <code>@QuarkusTest</code>, transactions in tests, mocks, and <code>@QuarkusIntegrationTest</code>.</p></li><li><p><a href="https://quarkus.io/guides/continuous-testing">Quarkus: Continuous Testing</a> if you want fast feedback while the unregister flow is still changing.</p></li><li><p><a href="https://docs.junit.org/current/user-guide/">JUnit User Guide</a> for the current programming model, extension model, and test organization options once the regression plan becomes a real suite.</p></li><li><p><a href="https://www.jacoco.org/jacoco/trunk/doc/counters.html">JaCoCo Coverage Counters</a> if you want branch, instruction, and complexity numbers to reflect the paths that actually matter.</p></li><li><p><a href="https://bob.ibm.com/">IBM Bob</a> if you want to try the workspace-aware analysis workflow Alex uses in the video.</p></li></ul><p>If your unregister flow goes through JPA or Hibernate relationships, read the deletion and orphan-handling rules in the ORM documentation for the exact version you run. That is one of those areas where small mapping details decide whether cleanup is correct or catastrophic.</p><h2><strong>Why I Like This Pattern</strong></h2><p>I like this workflow because it uses the assistant where assistants are actually useful: reading a codebase, building a candidate regression map, and giving you a sharper review checklist before implementation starts.</p><p>You still need to decide the delete semantics. You still need to own the transaction model. You still need to write the tests that prove the feature is safe.</p><p>But you do not need to build the first regression checklist from memory every single time. Let the tool help there. Save your energy for the decisions that still need an engineer.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.the-main-thread.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.the-main-thread.com/subscribe?"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item></channel></rss>