Layer 5 is done. casehub-engine is in clinical, the IRB gate works end-to-end, and Grade 3/4 adverse events now route adaptively rather than through a hardcoded switch. 115 tests, all green.

The decision that mattered before the first line of code

Before we touched the implementation, I had to decide what an engine case actually represents in clinical. The obvious framing — one case per trial site, accumulating all events — looked architecturally elegant. Events fire into a site case, context builds up, the same bindings handle both deviations and AEs.

I rejected it. An engine case is a bounded process with clear completion goals, not a container. A per-site case without the trial-level parent case is structurally orphaned — you’d be building half of Layer 6 and calling it Layer 5. The per-event approach (one case per CRITICAL deviation, one case per Grade 3+ AE) has trivially simple binding conditions, completes when the event is resolved, and composes cleanly into Layer 6’s sub-case architecture when engine#112 unblocks it.

That decision is captured as ADR 0001. It was worth making explicit — the per-site shape felt architecturally righteous in a way that needed pushing back on.

The CDI hell

The implementation itself went fast once the engine was actually wiring. Getting there was not fast.

casehub-engine landing in clinical’s classpath brought three separate startup failures, none of which had anything to do with the feature I was building.

The first was the most misleading: seven CDI deployment problems, all Unsatisfied dependency for type JQEvaluator. The instinct is to look at what changed — but JQEvaluator is an engine-internal type, and the engine had just been added. The actual cause was that casehub-platform and casehub-platform-expression are implicit requirements for the engine to start. They provide @DefaultBean mocks for injection points throughout the engine stack. Nothing in the engine documentation says this. I found it by diffing clinical’s pom.xml against devtown, which already had the engine working.

The second was Quartz. casehub-engine-scheduler-quartz transitively pulls in quarkus-quartz, which requires 6-7 field cron expressions. casehub-work’s scheduler beans (ExpiryCleanupJob, ClaimDeadlineJob, RoutingCursorCleanupJob) use standard Unix 5-field cron. Quartz fails at startup parsing them. The fix is to exclude those beans in test properties and configure quarkus.quartz.store-type=ram with quarkus.scheduler.start-mode=forced. Not documented anywhere.

The third was the approach a subagent took to fix problems one and two. It excluded twenty-odd engine beans — including CaseContextChangedEventHandler, CaseStartedEventHandler, the entire event handler stack — rather than finding the root cause. The tests passed with that approach. They would have continued passing until we tried to actually run an engine case in a test, at which point nothing would have worked. I caught it before the integration tests existed and reversed it.

The when field that does nothing

The implementation itself was straightforward: two YAML case definitions, four observer classes, two ledger subclasses, an SPI. The only runtime surprise came in AeEscalationLifecycleTest when Grade 3 AEs were creating DSMB escalation WorkItems they shouldn’t.

The ae-escalation.yaml had this binding:

- name: dsmb-escalation
  on: { contextChange: {} }
  when: ".requiresDsmbEscalation == true and .dsmbEscalation == null"

when is silently ignored for contextChange triggers. CaseContextChangedEventHandler evaluates on.contextChange.filter, not binding.getWhen(). The when field is only evaluated by the scheduler-side handler. Moving the condition to on.contextChange.filter: fixed it. Filed as engine#335.

What the integration tests verify

IrbGateLifecycleTest has two paths: APPROVED (case completes via outputMapping writing irbConsultation) and EXPIRED (no outputMapping fires on expiry; IrbDecisionListener signals the case directly with caseHub.signal(caseId, "irbConsultation", ...) to satisfy the irb-decided goal).

AeEscalationLifecycleTest has Grade 3 (one safety-review WorkItem, case completes when it resolves) and Grade 4 (two parallel WorkItems — senior-safety-monitors and dsmb — case completes when both resolve). Same YAML, different initial context.

The code reviewer Claude ran after implementation caught three real issues: missing @Transactional on AeEscalationListener, a null enrollmentId path that would have hit a nullable = false column, and clock.instant() called twice in the ledger writer. The first two were genuine data-integrity risks.

What’s left

Layer 6 (multi-site sub-case orchestration) is blocked on engine#112. The foundation for it is here — per-event cases become bindings within site sub-cases when that unblocks. Layer 7 is trust routing plus the ClinicalAgent comparison.

The AdverseEventEscalationPolicy SPI is the right shape. Every routing decision flows through it: candidateGroups for Grade 1/2, case context keys for Grade 3+. Organisations override it entirely. The tutorial doesn’t demonstrate that extensibility yet — it just shows the default — but the boundary is drawn correctly.


<
Previous Post
Forty-Three CDI Errors, Four Root Causes
>
Next Post
Scope and the Silent Guard