The last OHT gaps are closed. WorkItemTemplate now carries the full type contract: named outcomes, JSON Schema for payload and resolution, and an explicit exclusion list for conflict-of-interest enforcement. Three issues, two epics, one mess left behind by a merge gone wrong.

Deleting an entity is easy. The argument for it is harder.

WorkItemFormSchema was a category-level entity — it defined what a WorkItem’s payload and resolution should look like, keyed by category, stored separately from the template. When Phase 9 was written, templates were simple default-value containers. The schema lived elsewhere because the schema was the type definition, and “type” was “category.”

That’s no longer true. Templates now carry outcomes, assignment strategies, business-hour deadlines, spawn configuration, and routing rules. A template is the type. Having the schema in a separate entity means two things describe the same unit of work, with unclear precedence between them.

I wanted to delete WorkItemFormSchema entirely rather than add a parallel template-level schema alongside it. The argument Claude raised — that the UI could still use the form schema as a hint for rendering — didn’t hold up. Whatever the UI needed to render, the template could provide: GET /workitem-templates/{id} now returns inputDataSchema and outputDataSchema directly. No information was lost. One endpoint replaced two.

The deletion was about 1,200 lines removed, including the entity, the CRUD resource, and the category-level validation that had lived in WorkItemResource. Validation moved to WorkItemService — where it should have been — and the service now holds FormSchemaValidationService directly. The rest of the codebase didn’t miss WorkItemFormSchema.

Conflict of interest as a policy point

For the exclusion feature (#171), there was a short argument about whether to put ExclusionPolicy on the API as a proper SPI or just bake the comma-separated field check directly into the service. I pushed for the SPI approach: ExclusionPolicy is a policy concern, and the platform already has SPIs for all its policy concerns — EscalationPolicy, ClaimSlaPolicy, WorkerSelectionStrategy. A comma-separated field check as the @DefaultBean implementation covers the OHT use case (the initiator can’t approve their own task). LDAP group membership, time-window exclusions, and trust-score-based gates are all plausible without changing the core.

The enforcement spans five points: claim(), create() for the direct assignee, delegate(), the auto-assignment candidate filter in resolveCandidates(), and SelectionContext so external selection strategies have access to the exclusion list. That last one matters. A custom WorkerSelectionStrategy that builds its own candidate list from an external directory would bypass service-layer filtering. Carrying excludedUsers in SelectionContext makes the constraint part of the selection contract, not an implementation detail of the default path.

clone() deliberately carries excludedUsers forward. A WorkItem cloned from one with conflict-of-interest rules should inherit those rules — a clean-slate clone would be a bypass vector.

The merge that silently dropped a feature

This is the part worth remembering.

A separate Claude session — working in the wrong project repository — ran an epic hygiene scan on casehub-work and merged both epics to main using git cherry-pick -X ours to resolve conflicts. The strategy takes “our side” for any conflicting hunk, which meant any file that existed in both branches and had a conflict kept the earlier branch’s version. No warning. The commit appeared in git log. git status was clean.

The result: the excludedUsers feature’s service-layer enforcement survived (those files weren’t conflicting), but everything else — the REST surface, response types, mapper, template resource, context builder — was silently reset to the output-schema version. Running the feature’s tests showed five failures immediately. WorkItemExcludedUsersTest.claim_byExcludedUser_returns409 expected 409, got 200.

Tracing it took a few minutes. WorkItemService.claim() had the enforcement. WorkItemMapper.toResponse() had null where wi.excludedUsers should have been. The mapper had kept the output-schema version, which predated the excluded-users parameter. Service validated; REST ignored; response always null.

We fixed it across 41 files. The lesson is specific: git cherry-pick -X ours is not a safe way to merge concurrent feature branches. If two branches both added capabilities to the same files — which they will, when the features are related — the strategy will silently discard one of them. The only safe merge for concurrent features is manual conflict resolution or sequential merge with rebase.

Flyway migration numbers in concurrent branches

The two epics both started from V22 on main. Both independently picked V23 and V24. Flyway enforces uniqueness even on a fresh install — two V23 files in the same application classpath fails at startup before a single migration runs.

The fix was straightforward: renumber one set. The epics landed as V23–V25 (schema validation) and V26–V27 (excluded users). The harder part is that there was no early signal. Each branch ran its own tests cleanly. The conflict only surfaces when both are on the classpath together at merge time.


<
Previous Post
The right fix, the wrong hypothesis
>
Next Post
How provider-agnostic really works