I knew going into this that connectors#6 was going to be harder than it looked. The issue title — “InboundMessage CDI event → MessageService.dispatch()” — sounds like a bridge: two things you connect with a cable. It turned out to be an architecture problem dressed as a plumbing problem.

The starting point was a design session that I’d expected to take an hour and took most of a day. Before anything compiled, I needed to figure out where the bridge should live, what the channel granularity should be, and what externalChannelRef actually means in practice. That last one is where the real design insight came from.

I read through all four inbound connectors before proposing anything. What I found: externalChannelRef is always our own endpoint. For Twilio SMS, it’s params.get("To") — our Twilio number. For WhatsApp, it’s phone_number_id — our Meta phone ID. For email, it’s the recipient address — our inbox. The sender is always externalSenderId. This matters because if you’re going to do per-conversation Qhorus channels, you need to key on the sender, not the channel ref. The channel ref just says “this arrived at our system” — it doesn’t say “this is a conversation with Alice.”

I’d initially planned three options — a CDI event decoupling approach (Approach B), the full HumanParticipatingChannelBackend integration (Approach C), and a simple runtime dependency approach (A). After reading the ChannelGateway code and all the existing backend implementations (Claudony, OpenClaw), I recommended B and the user picked C. C is architecturally cleaner because it wires into the existing backend lifecycle: ChannelInitialisedEvent for registration, post() for outbound, and a proper binding in the channel entity rather than external configuration.

The brainstorming had a code review baked in by design. I wrote the spec, got a thorough review back with a critical finding (the original spec had @ObservesAsync receiving from a synchronous Event.fire() — that’s a CDI 2.0 no-op; the spec was wrong), and revised it twice before touching any code. The review also surfaced the ChannelConnectorBinding table design — I’d initially added four nullable columns to the Channel entity, which is the standard fat-entity smell. A separate binding table with channelId as PK makes the “all four or none” invariant structural, not application-enforced. That’s the right call and I wish I’d seen it first.


The implementation was 18 tasks across two repos. I brought Claude in as the implementer and ran code reviews after every task. The structure was strict: spec compliance first, code quality second, no skipping.

The InboundConnectorService change came first: swap messageEvent::fire for a fireAsync() lambda with an .exceptionally() handler. Simple change, significant contract break — every existing @Observes InboundMessage consumer would now get nothing. There were zero production consumers, so the blast radius was clean, but we still had to update both test capture beans from @Observes to @ObservesAsync with BlockingQueue-based patterns to match. The tests confirm the TDD intention: set them up to fail first (ICS still sync, capture uses async observer), then fix ICS, watch them pass.

The qhorus data layer went smoothly until it didn’t. ChannelBindingStore follows the store seam protocol — interface in runtime/store, JPA impl in runtime/store/jpa, in-memory in testing/. ChannelCreateRequest is a compact-constructor record that enforces the binding invariant. ChannelDetail gained a ConnectorBinding nested record as the 14th field — a binary break on the canonical constructor, which required updating QhorusEntityMapper and QhorusDashboardService. The review caught that both had independent implementations of the binding lookup, which would diverge silently. The fix was to have the dashboard delegate to the mapper instead of maintaining its own copy.

The ConnectorChannelBackend itself is one @ApplicationScoped bean with an in-memory ConcurrentHashMap cache keyed on channel UUID. Registration happens via @Observes ChannelInitialisedEvent — it checks the binding store, populates the cache, then deregisters-and-registers with the gateway (idempotent, same pattern as ClaudonyChannelBackend). Inbound routing uses @ObservesAsync InboundMessage, derives the lookup key via ConnectorKeyStrategy (externalSenderId for SMS/WhatsApp/Email, externalChannelRef for Slack), and delegates to gateway.receiveHumanMessage(). Outbound delivery reads from the cache in post() — no database access during fanOut, which is important because fanOut runs in a virtual thread.

ConnectorKeyStrategy is the architectural heart. The table it encodes:

Connector Lookup key Why
slack-inbound externalChannelRef Slack channel IS the conversation
twilio-sms-inbound externalSenderId externalChannelRef is our number
whatsapp-inbound externalSenderId externalChannelRef is our phone ID
email-inbound externalSenderId externalChannelRef is our inbox

No framework magic. Just a Set.of(...) with the sender-keyed connectors and a default fallback.


The integration test took the most debugging time, and for reasons that had nothing to do with the code.

Running mvn test -pl connector-backend fails with casehub-ledger-deployment-0.2-SNAPSHOT.jar does not exist because Quarkus @QuarkusTest needs the deployment JARs of every extension on the classpath for augmentation, and those jars only exist after mvn install — not after compile or test-compile. This is a multi-repo problem: if casehub-ledger hasn’t been installed to ~/.m2 (with its deployment module), the test can’t augment. The fix is to mvn install -DskipTests the dependency chain before running isolated tests.

There was also the *.lastUpdated issue. After a GitHub Packages 401, Maven writes failure markers to ~/.m2 that persist past the session. Fixing auth doesn’t help — Maven reads the marker and refuses to retry. You have to delete the .lastUpdated files manually (or via Python pathlib) before the next resolution attempt will succeed. I lost a couple of hours to this before finding the pattern.

The connector-backend pom also had a wrong artifactId: I’d written casehub-qhorus-runtime but the runtime module’s actual Maven artifactId is casehub-qhorus. That’s a naming choice from before the module structure was fully settled — the folder is runtime/ but the artifact is the parent name. The test couldn’t find the JAR, Maven fell through to GitHub Packages, 401, cached failure, confusion. Once the artifactId was fixed the dependency graph resolved correctly.


The final code review found two real issues I’d missed. ChannelCreateRequest’s compact constructor checked inboundConnectorId != null → all four required but not the reverse: if you passed externalKey = "something" with the others null, it silently accepted it. The invariant comment said “all four or none” but the code said something weaker. Fixed to check anySet && !allSet before throwing.

The second was QhorusDashboardService maintaining its own toChannelDetail() method independently of QhorusEntityMapper — both building ChannelDetail.ConnectorBinding with their own binding store lookups. A one-line delegate call fixes the duplication. The kind of thing that looks fine in isolation and becomes a maintenance sink when the binding mapping needs to change.

Both fixed, committed, done.


What ships: ConnectorChannelBackend in casehub-qhorus, wired to InboundMessage from casehub-connectors, per-conversation channel granularity, pre-provisioned bindings only in v1. Six deferred issues filed: auto-channel creation, cache refresh on binding update, per-connector normaliser, MCP tools, overload consolidation, and the magic string constants in ConnectorKeyStrategy.

The most interesting architectural finding from this whole session: externalChannelRef has been carrying the wrong semantic from the start. It identifies our endpoint — the Twilio number we own, the inbox we receive at — not the conversation partner. That’s fine for Slack where the channel is genuinely the conversation space, but it means the bridge has to know, per connector type, which field actually identifies who you’re talking to. That asymmetry is now encoded in ConnectorKeyStrategy and will need to stay there until someone rethinks what externalChannelRef means at the source.


<
Previous Post
Two mappers, one exception
>
Next Post
Silence is not an audit trail