What brought us here was a closed issue — engine#231 finally threading triggerChannelId and triggerCorrelationId through to ProvisionContext. The provision path in Claudony has carried a TODO for weeks: when a Qhorus COMMAND triggers a researcher case, link the resulting CaseLedgerEntry back to that COMMAND’s MessageLedgerEntry via causedByEntryId. The scaffold was already in place — a ConcurrentHashMap in ClaudonyReactiveWorkerProvisioner, a drain in ClaudonyLedgerEventCapture — waiting for the engine to send the fields it needed.

Getting there required understanding why the obvious threading pattern fails.

The first design proposed chaining the Qhorus DB lookup after the blocking tmux setup: .runSubscriptionOn(workerPool).flatMap(ignored -> resolver.resolve(...)). The problem is buried in Quarkus internals — SessionOperations.vertxContext() calls VertxContextSafetyToggle.validateContextIfExists(), which requires a safe (isolated) Vert.x sub-context. Worker pool threads from runSubscriptionOn() don’t have one. The error isn’t “no Vert.x context” — they do have a context. It’s that the context isn’t the right kind.

The fix is Uni.combine(). Build both Unis before any thread switch, while still on the event loop where provision() is invoked. The blocking tmux work runs on the worker pool; QhorusCausalLinkResolver.resolve() is called on the event loop, where @WithSession("qhorus") can intercept via the CDI proxy and capture the correct context. When Uni.combine() subscribes both, the reactive Panache query runs within that session regardless of which thread later handles the emission.

Uni<Void> setup = Uni.createFrom()
    .<Void>item(() -> { setupSession(capabilities, context); return null; })
    .runSubscriptionOn(Infrastructure.getDefaultWorkerPool());

Uni<Optional<UUID>> causedBy =
    (causalLinkResolver != null
     && context.triggerChannelId() != null
     && context.triggerCorrelationId() != null)
    ? causalLinkResolver.resolve(context.triggerChannelId(), context.triggerCorrelationId())
    : Uni.createFrom().item(Optional.empty());

return Uni.combine().all().unis(setup, causedBy).asTuple()
    .invoke(tuple -> { /* causalContext.put */ })
    .map(tuple -> new ProvisionResult(tuple.getItem2().orElse(null)))
    ...

The pre-construction contract is the interesting design constraint. @WithSession is a CDI interceptor that fires when the method is called, not when the Uni is subscribed to. Call resolver.resolve() from the event loop and the session gets bound to the event loop’s safe context. That binding persists through the async subscription.

The JPQL query was its own surprise. ReactiveMessageLedgerEntryRepository.findLatestByCorrelationId(channelId, correlationId, tenancyId) looks up by channel — but the JPQL uses subjectId, not channelId. Both columns hold the same UUID on a correctly-seeded MessageLedgerEntry, but if you only set channelId when testing, the query returns silently empty. The method parameter is named channelId; the predicate is subjectId = ?1. No error, no warning.

The null guard in provision() is worth noting. The extended condition — causalLinkResolver != null && context.triggerChannelId() != null && context.triggerCorrelationId() != null — must be at the Uni construction site, before Uni.combine(). If only the resolver null-check is in place and a non-null mock resolver receives null trigger fields, resolver.resolve(null, null) returns Mockito’s default null for the Uni, and Uni.combine().all().unis(setup, null) NPEs on subscribe. The trigger field guards belong at the same level as the resolver guard.

The integration test for QhorusCausalLinkResolver blocks on qhorus#280 — MessageLedgerEntryTestFactory lives in the runtime module’s test sources rather than casehub-qhorus-testing, so Claudony can’t reference it. The fix is a small cross-repo move (the factory has no test-framework dependencies), and the issue is filed.

The SignalReceivedEventHandler problem from the previous session remains. The engine SNAPSHOT doesn’t fire CaseContextChangedEvent after the exited signal, so cases stay at RUNNING even after the watcher fires correctly. Filed as engine#493. The provision path and causal chain work. The completion chain doesn’t.


<
Previous Post
The Pipeline That Broke the Runtime
>
Next Post
The Tenant Whose Key Was Always the Same