Summary
Implements the full HITL loop for human-task case bindings. The inbound path already exists (WorkItemLifecycleAdapter, 8 tests passing — casehubio/work#136). This issue covers the outbound path and the architectural changes that enable it.
Refs #115 (Human Escalation epic). Closes casehubio/work#136.
Prerequisite: casehubio/work#165 (WorkItemTemplateService.instantiate() callerRef overload).
Full design spec: wksp/specs/2026-05-12-human-task-binding-design.md
What to build
1. BindingTarget sealed interface — casehub-engine-api
Replace Binding's nullable capability + subCase fields with a single sealed BindingTarget target:
public sealed interface BindingTarget
permits CapabilityTarget, SubCaseTarget, HumanTaskTarget, ExtensionTarget {}
public record CapabilityTarget(Capability capability) implements BindingTarget {}
public record SubCaseTarget(SubCase subCase) implements BindingTarget {}
public non-sealed interface ExtensionTarget extends BindingTarget {} // runtime plugin escape hatch — no dispatcher yet
Binding.target() replaces getCapability() / getSubCase(). Builder retains typed convenience methods. CaseContextChangedEventHandler switches exhaustively with pattern matching:
switch (binding.target()) {
case CapabilityTarget t -> publishWorkerSchedule(...)
case SubCaseTarget t -> publishSubCaseSchedule(...)
case HumanTaskTarget t -> publishHumanTaskSchedule(...)
case ExtensionTarget t -> LOG.warnf("No handler for ExtensionTarget %s", t.getClass().getName())
}
2. HumanTaskTarget — casehub-engine-api
Pure-Java record (no Quarkus, no casehub-work types). Two factory entry points:
// Template ref — reusable, operationally managed in casehub-work
HumanTaskTarget.template("irb-72h-review")
.inputMapping("{ description: (\"Trial \" + .trialId) }")
.outputMapping("{ irbOutcome: .decision }")
.build()
// Inline — self-contained, one-off
HumanTaskTarget.inline()
.title("IRB Ethics Committee Review")
.candidateGroups(Set.of("ethics-committee"))
.expiresIn(Duration.ofHours(72))
.build()
Both inputMapping and outputMapping accept String (JQ by default, same as Binding.when(String)) or ExpressionEvaluator instance (lambda or custom evaluator). priority is a plain String — no casehub-work types in the API module.
3. PlanItem stores BindingTarget — casehub-engine-blackboard
public static PlanItem create(String planItemId, String workerName, int priority, BindingTarget target)
Backward-compatible 3-arg overload sets target = null. Wherever the planning loop creates plan items (already has Binding objects), pass binding.target(). This enables outputMapping lookup at completion time for all binding types — not just HumanTaskTarget.
4. Outbound — engine runtime
EventBusAddresses.HUMAN_TASK_SCHEDULE (new constant)
HumanTaskScheduleEvent record: UUID caseId, String planItemId, HumanTaskTarget target, Map<String, Object> inputData
publishHumanTaskSchedule() in CaseContextChangedEventHandler: evaluates inputMapping against CaseContext before publishing (same pattern as publishSubCaseSchedule)
5. HumanTaskScheduleHandler — casehub-engine-work-adapter
@ConsumeEvent(EventBusAddresses.HUMAN_TASK_SCHEDULE)
public void onHumanTaskSchedule(HumanTaskScheduleEvent event) {
// 1. Look up PlanItem via BlackboardRegistry
// 2. planItem.markRunning() ← prevents re-evaluation before WorkItem completes
// 3. callerRef = CallerRef.encode(caseId, planItemId)
// 4a. template mode: workItemTemplateService.instantiate(template, ..., callerRef)
// 4b. inline mode: workItemService.create(new WorkItemCreateRequest(... callerRef ...))
}
6. WorkItemLifecycleAdapter — extended for outputMapping
After marking PlanItem and before firing CONTEXT_CHANGED:
if (item.target() instanceof HumanTaskTarget t && t.outputMapping() != null && workItem.resolution != null) {
try {
instance.getCaseContext().putAll(t.outputMapping().evaluate(workItem.resolution));
} catch (Exception e) {
LOG.warnf(e, "outputMapping failed for PlanItem %s — CONTEXT_CHANGED fires without output", planItemId);
}
}
No separate registry — PlanItem.target() is the source of truth.
Full data flow
1. CaseContext condition met → CaseContextChangedEventHandler
2. binding.target() = HumanTaskTarget → publishHumanTaskSchedule()
3. inputMapping evaluated → inputData
4. HumanTaskScheduleEvent on Vert.x event bus
5. HumanTaskScheduleHandler:
a. PlanItem.markRunning()
b. callerRef = CallerRef.encode(caseId, planItemId)
c. WorkItem created (template or inline) with callerRef
6. Human acts → WorkItem terminal state
7. WorkItemLifecycleEvent (carries callerRef + resolution)
8. WorkItemLifecycleAdapter:
a. CallerRef.parse() → caseId + planItemId
b. PlanItem.mark*()
c. outputMapping → CaseContext updated
d. CONTEXT_CHANGED → engine re-evaluates
10. Case proceeds
Error handling
| Scenario |
Behaviour |
CasePlanModel not found |
Warn + return |
PlanItem not found |
Warn + return |
| Template ID not found |
IllegalArgumentException — binding stays eligible |
WorkItemService throws |
Log + propagate |
outputMapping eval fails |
Warn — CONTEXT_CHANGED still fires |
| Completion after case completed |
registry.get(caseId) empty → warn + return |
Module touch points
| Module |
Changes |
casehub-engine-api |
BindingTarget sealed + 4 types; HumanTaskTarget with builder; Binding refactored |
casehub-engine-blackboard |
PlanItem gains BindingTarget target; planning loop updated |
casehub-engine runtime |
EventBusAddresses; HumanTaskScheduleEvent; CaseContextChangedEventHandler |
casehub-engine-work-adapter |
HumanTaskScheduleHandler (new); WorkItemLifecycleAdapter extended |
Tests
HumanTaskTargetTest: builder, both modes, ExpressionEvaluator overloads
BindingTest: sealed target update
PlanItemTest: target field round-trip
CaseContextChangedEventHandlerTest: HumanTaskTarget branch publishes event with pre-evaluated inputData
HumanTaskScheduleHandlerTest (new): template + inline modes, callerRef, PlanItem RUNNING
WorkItemLifecycleAdapterTest extended: outputMapping writes to CaseContext; eval failure → CONTEXT_CHANGED still fires
WorkItemRoundTripTest (new): full end-to-end binding → WorkItem → completion → CaseContext updated
Summary
Implements the full HITL loop for human-task case bindings. The inbound path already exists (
WorkItemLifecycleAdapter, 8 tests passing — casehubio/work#136). This issue covers the outbound path and the architectural changes that enable it.Refs #115 (Human Escalation epic). Closes casehubio/work#136.
Prerequisite: casehubio/work#165 (
WorkItemTemplateService.instantiate()callerRef overload).Full design spec:
wksp/specs/2026-05-12-human-task-binding-design.mdWhat to build
1.
BindingTargetsealed interface —casehub-engine-apiReplace
Binding's nullablecapability+subCasefields with a single sealedBindingTarget target:Binding.target()replacesgetCapability()/getSubCase(). Builder retains typed convenience methods.CaseContextChangedEventHandlerswitches exhaustively with pattern matching:2.
HumanTaskTarget—casehub-engine-apiPure-Java record (no Quarkus, no casehub-work types). Two factory entry points:
Both
inputMappingandoutputMappingacceptString(JQ by default, same asBinding.when(String)) orExpressionEvaluatorinstance (lambda or custom evaluator).priorityis a plainString— no casehub-work types in the API module.3.
PlanItemstoresBindingTarget—casehub-engine-blackboardBackward-compatible 3-arg overload sets
target = null. Wherever the planning loop creates plan items (already hasBindingobjects), passbinding.target(). This enablesoutputMappinglookup at completion time for all binding types — not justHumanTaskTarget.4. Outbound — engine runtime
EventBusAddresses.HUMAN_TASK_SCHEDULE(new constant)HumanTaskScheduleEventrecord:UUID caseId,String planItemId,HumanTaskTarget target,Map<String, Object> inputDatapublishHumanTaskSchedule()inCaseContextChangedEventHandler: evaluatesinputMappingagainstCaseContextbefore publishing (same pattern aspublishSubCaseSchedule)5.
HumanTaskScheduleHandler—casehub-engine-work-adapter6.
WorkItemLifecycleAdapter— extended foroutputMappingAfter marking
PlanItemand before firingCONTEXT_CHANGED:No separate registry —
PlanItem.target()is the source of truth.Full data flow
Error handling
CasePlanModelnot foundPlanItemnot foundIllegalArgumentException— binding stays eligibleWorkItemServicethrowsoutputMappingeval failsCONTEXT_CHANGEDstill firesregistry.get(caseId)empty → warn + returnModule touch points
casehub-engine-apiBindingTargetsealed + 4 types;HumanTaskTargetwith builder;Bindingrefactoredcasehub-engine-blackboardPlanItemgainsBindingTarget target; planning loop updatedcasehub-engineruntimeEventBusAddresses;HumanTaskScheduleEvent;CaseContextChangedEventHandlercasehub-engine-work-adapterHumanTaskScheduleHandler(new);WorkItemLifecycleAdapterextendedTests
HumanTaskTargetTest: builder, both modes, ExpressionEvaluator overloadsBindingTest: sealed target updatePlanItemTest: target field round-tripCaseContextChangedEventHandlerTest: HumanTaskTarget branch publishes event with pre-evaluated inputDataHumanTaskScheduleHandlerTest(new): template + inline modes, callerRef, PlanItem RUNNINGWorkItemLifecycleAdapterTestextended: outputMapping writes to CaseContext; eval failure → CONTEXT_CHANGED still firesWorkItemRoundTripTest(new): full end-to-end binding → WorkItem → completion → CaseContext updated