Epic 6 was supposed to be the wiring-up epic. COMMAND arrives on a Qhorus work channel, gets forwarded to OpenClaw via /hooks/agent, OpenClaw completes, POSTs the result back, the result lands on the channel. Most of the pieces existed. I thought it would take a day.

The bidirectional wiring took about a day. But the design underneath it took longer, because one question kept expanding.

The risk gate that belongs in the engine

The issue brief included an oversight gate: when an OpenClaw agent is about to take a consequential action, pause, route to a human, wait for approval. The question was where ActionRiskClassifier — the thing that decides whether to pause — should live.

My first instinct was to put it in casehub-openclaw. Finance agents cancelling subscriptions, booking flights, spending money — who else needs it?

Then I thought about it properly. Claudony runs Claude CLI agents. A devtown review-agent could merge a PR with a security finding. A clinical agent could escalate an adverse event. An AML agent could file a SAR with the regulator. Every one of those is a worker about to take a consequential action that a human should have the option to stop.

The pattern is platform-wide. The SPI belongs in casehub-engine-api so any worker — OpenClaw, Claudony, whatever comes next — can use a uniform gate without building bespoke approval logic.

I filed casehubio/engine#402: classify(PlannedAction) → RiskDecision, either Autonomous (proceed) or GateRequired(reason, reversible). Then filed issues on claudony, devtown, clinical, aml, and life with domain-specific examples. Clinical alone has several mandatory cases where this matters: SUSAR filing always requires a clinician to sign off; the ledger record of who approved what is itself a compliance artefact, not just an audit trail.

For casehub-openclaw in Phase 1: a local ActionRiskClassifier interface with a default that always returns Autonomous. The gate is fully wired and tested; it never fires in production until someone registers a real risk classifier. Javadoc says the migration to engine-api is a pure import swap.

Durable by design

OversightGateService.fulfill() receives the human’s response to a gate and needs to know which work channel to dispatch to. The gateId in the URL is the key; the question is what it maps to.

The obvious answer: a ConcurrentHashMap<UUID, PendingGate> in memory. O(1) lookup, simple to reason about.

The problem: if the app restarts while a gate is open, the map is gone. The human’s response arrives to /openclaw/delivery/oversight/{gateId}, we find nothing, fail open, and the case step hangs until Watchdog fires. That’s the kind of bug filed six months later when someone can’t explain why a case stalled.

Instead, Claude and I used the Qhorus Commitment that opens automatically when the gate fires. MessageService.dispatch(COMMAND, correlationId=gateId) creates a Commitment in the database. After a restart, commitmentStore.findByCorrelationId(gateId) still works. The oversight channel is the audit trail; Watchdog handles escalation for free; no new storage needed.

fulfill() then looks up the Commitment for the oversightChannelId, extracts the caseId from the channel name, finds the work channel by name, and dispatches RESPONSE or DECLINE. Four indexed queries. But they’re against a database that’s already there.

A code review recommended switching to the in-memory map — simpler, fewer lookups, no external API dependency. I held that position. The in-memory approach solves the happy path and makes the failure mode worse. The durable path is four queries.

What the builder actually requires

I expected dispatching MessageType.DONE to a work channel to be straightforward. It’s not. MessageDispatch.Builder.build() requires both inReplyTo (the Long database primary key of the original COMMAND message) and correlationId for DONE, DECLINE, RESPONSE, and FAILURE. The intent makes sense — resolving a commitment requires identifying which commitment to resolve. The consequence is that you can’t dispatch DONE from a delivery webhook unless you tracked the original COMMAND’s Long ID at invocation time.

The OutboundMessage passed to ChannelBackend.post() doesn’t expose that ID. It uses UUID.randomUUID() as its own identifier in fanOut(). So at delivery time, we don’t have it.

Phase 1 workaround: dispatch STATUS instead. STATUS acknowledges the obligation without resolving it. Not the right type semantically, but the Commitment stays open and Watchdog handles the hanging case until openclaw#16 is fixed properly.

The gate’s own RESPONSE/DECLINE dispatches are different — we dispatch the oversight COMMAND ourselves in openGate(), so we can retrieve its Long ID via messageService.findAllByCorrelationId(gateId) and supply the correct inReplyTo. That path dispatches properly.

The oversight channel gap

The oversight channel had allowedTypes = "COMMAND,QUERY". That’s what Claudony’s NormativeChannelLayout uses, and we’d copied it. The problem: RESPONSE and DECLINE — which close the gate Commitment — aren’t in that list.

I could have just added them and moved on. But thinking about what the oversight channel actually needs over time: a human asking for context (QUERY back), an agent clarifying (STATUS), a telemetry event (EVENT). The COMMAND+QUERY restriction rules out most of a real oversight conversation.

I set it to null for casehub-openclaw and filed casehubio/claudony#142 asking them to think hard about whether null, a tiered model (gate channel vs. conversation channel), or new message types was the right long-term answer. The note to be genuinely skeptical about the current design, not just patch the gap.

The test

BidirectionalWiringIT is a @QuarkusTest with real Qhorus InMemory stores, real CDI wiring, and the OpenClaw gateway mocked. Register a test agent, dispatch a COMMAND, verify the gateway gets the right /hooks/agent body, POST back to the delivery endpoint, assert STATUS lands on the work channel. For the gate path: override ActionRiskClassifier via @InjectMock to return GateRequired, fire the delivery, trace the second gateway invoke carrying the oversight URL, then POST “approved” to the oversight endpoint and verify RESPONSE on the oversight channel and DONE on the work channel.

One wrinkle: messageService.findAllByCorrelationId() uses a Panache static call that goes to H2 directly, bypassing the InMemory store. The InMemory beans are correctly injected; they just never receive the Panache query. We worked around it with @InjectSpy on MessageService — intercept just that method, delegate to the InMemory store directly.

Nine cases. All green.


<
Previous Post
Asking Directories Who's in the Group
>
Next Post
CaseHub Eidos — Three Issues, One Pipeline