Skip to content

feat: HumanTaskTarget binding — outbound WorkItem creation + outputMapping round-trip #245

@mdproctor

Description

@mdproctor

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. HumanTaskTargetcasehub-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 BindingTargetcasehub-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. HumanTaskScheduleHandlercasehub-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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions