The previous branch left the @QuarkusTest suites in casehub-blackboard blocked — RoutingCursorStore unsatisfied dependency. The fix was straightforward: add quarkus.arc.exclude-types=io.casehub.work.core.strategy.RoundRobinStrategy to the test configs. RoundRobinStrategy lives in casehub-work-core and requires a RoutingCursorStore provider that doesn’t exist in the engine’s test context. The proper fix is a @DefaultBean no-op in casehub-work, filed as casehubio/work#214. For now, the exclusion lets CDI ignore it.

With the tests unblocked, we could see what the blackboard actually needed. The stated problem was “SubCase PlanItems never transition beyond PENDING.” Once we looked, it was wider than a missing state transition.

Every SubCase and HumanTask PlanItem in the blackboard carried the status RUNNING. That’s wrong. RUNNING means a Quartz thread is actively computing. A SubCase PlanItem is waiting for a child case to return — the engine has handed off and gone idle. An LLM reading RUNNING on that PlanItem would infer active local computation. It’s a misrepresentation of what the system is doing.

I asked whether this needed a new state rather than a workaround. The answer required a full systematic review — all target types, all terminal scenarios, every error path. The result was a 6-state model: PENDING, RUNNING, DELEGATED, COMPLETED, FAULTED, CANCELLED. RUNNING stays for CapabilityTarget only. DELEGATED means “control passed to an external actor; the engine is waiting for a completion signal.” No ambiguity.

The implementation found a second problem immediately. DefaultCasePlanModel.getAgenda() only returns PENDING items — the code filtered on status == PENDING, full stop. When we tried to find a DELEGATED PlanItem via the agenda stream, we got an empty result with no error. The fix was to use getPlanItemByBindingName() instead, updating that method and hasActivePlanItem() and addPlanItemIfAbsent() to treat DELEGATED as active alongside PENDING and RUNNING. Without that, a DELEGATED PlanItem wouldn’t block the binding from spawning a second child case.

The completion path was also missing. Worker completion (CapabilityTarget) already flowed through PlanItemCompletionHandler, which fires stage autocomplete and a PlanItemCompletedEvent. SubCase completion didn’t — SubCaseCompletionService updated the parent case state but never touched the PlanItem. We added a SubCaseExecutionCompleted event, published by SubCaseCompletionService after the parent resumes, consumed by PlanItemCompletionHandler. Both paths now use the same completePlanItemByKey() method. Stage autocomplete fires for SubCase bindings.

We also closed the error paths. If startCase() throws, or the CaseDefinition doesn’t exist, or the group configuration is invalid — the PlanItem was previously left in PENDING. hasActivePlanItem() returns true for non-terminal states, so a stuck PENDING binding never reschedules. Each error path now calls faultPlanItem().

One thing caught during the work: Java 21 pattern-matching switch doesn’t match null with default. A traditional switch treats default as the catch-all for everything including null; pattern-matching switch doesn’t — it throws NPE unless case null is declared explicitly. The fix is case null, default -> { ... }.

The HumanTaskScheduleHandler was also calling markRunning() when creating WorkItems. Fixed to markDelegated().


<
Previous Post
The Design That Went Somewhere Else
>
Next Post
The last piece of the diff viewer was the hardest