ADR-#: 1 Authors: Pablo Takara Reviewers: Teddy Crépineau, Ram Narayan Balaji Date: February 27, 2026 Status: Proposed
Migrate incident lifecycle into a governance workflow using a new Task Lifecycle Node. The node uses OpenMetadata tasks as the source of truth (not Flowable UserTask), receives a template with configurable statuses, and exposes each status transition to the main workflow graph via process variables. Users wire hooks on any transition using standard edges. Non-terminal statuses loop back; terminal statuses auto-close the task.
The Incident Manager handles the lifecycle of data quality incidents in OpenMetadata. When a test case fails, an incident is created; it progresses through New → Ack → Assigned → Resolved as humans triage it.
Today, this lifecycle is a switch statement in TestCaseResolutionStatusRepository.storeInternal(). It handles state transitions, task creation, assignment, and resolution. The state machine is simple, correct, and performant, but it has no extension points. Adding a behavior like "on Assigned, notify via Slack" or "on New, auto-assign to table owner" requires modifying repository code, testing, and redeploying.
Meanwhile, OpenMetadata ships a governance workflows framework built on Flowable BPM. It is fully configurable via REST API and UI. Users configure workflows as abstract trigger → nodes → edges graphs (they never see BPMN XML). The backend compiles these to Flowable process definitions automatically via NodeFactory and MainWorkflow.
The two systems live side by side but do not interact.
Additionally, the task refactor promotes tasks to first-class entities with standard ChangeEvents. This enables Flowable to be notified of every status transition — not just resolution — unlocking configurable hooks on any transition from day one.
- No auto-close when tests pass.
TestCaseResultRepository.setTestCaseResultIncidentId()setsincidentId = nullwhen a test succeeds but never resolves the incident or closes its task. - No auto-assign on incident creation. Every incident starts in
Newand requires manual acknowledgement. - No extensibility. Organizations cannot define configurable rules like "on any status change, execute action X" without code changes.
- Fixed lifecycle. The
New → Ack → Assigned → Resolvedstates are hardcoded. Organizations with different triage processes have no way to customize. - No incident TTL. No mechanism to auto-close stale incidents.
- 5M assets, 10-30% with data quality tests = 500K-1.5M test cases
- At 2-5% failure rate = 10K-75K concurrent open incidents (typical)
getOrCreateIncident()enforces one unresolved incident per test case
UC-1 — Auto-close incident when test passes The system automatically resolves the open incident (reason: AutoResolved) and closes its task. No human intervention required.
UC-2 — Auto-assign incident on creation When a new incident is created, the system automatically assigns it to a configured user or team.
UC-3 — Auto-close stale incidents (TTL) An incident open longer than a configurable deadline is automatically resolved (reason: Expired).
UC-4 — User-defined hooks on any status transition Users wire follow-up steps (notifications, Jira tickets, etc.) on any status change via workflow edges — no code changes.
A new governance workflow node that does NOT use Flowable's BPMN UserTask. It creates an OpenMetadata task, waits for status changes via IntermediateCatchEvent, and exposes each status to the parent workflow for routing.
Internal BPMN structure:
┌─ SubProcess ──────────────────────────────────────────────────────┐
│ │
│ [Start] → [Setup] → [Gateway: created?] │
│ │ no → [End: skip] │
│ │ yes ↓ │
│ │ [IntermediateCatchEvent: wait] │
│ │ ↓ message with {status} │
│ │ [Gateway: terminal?] │
│ │ yes → [CloseTask] → [SetResult] → [End] │
│ │ no → [SetResult] → [End] │
│ │ │
│ │ Setup (idempotent): │
│ │ • Check for existing open incident │
│ │ → if exists with active process: skip │
│ │ → if orphaned process: terminate it │
│ │ • Create incident record (New) │
│ │ • Create OM task │
│ │ • Auto-assign (from template config) │
│ │ • Set process variable omTaskId = task UUID │
│ │
│ + [TTL Boundary Timer: configurable, interrupting] │
│ → [AutoResolve via repository] → [End] │
└────────────────────────────────────────────────────────────────────┘
Node config:
{
"type": "taskLifecycleNode",
"config": {
"template": "incident",
"statuses": ["New", "Ack", "Assigned", "Resolved"],
"terminal": ["Resolved"],
"responsibles": { "source": "tableOwner" },
"ttl": "P30D"
}
}The node:
- Setup — Creates the OM task (idempotent on re-entry). Sets
omTaskIdprocess variable. - Wait —
IntermediateCatchEventwithmessageExpression="${omTaskId}". Subscribes to a message named after the task UUID (~2 Flowable DB rows). - On message — Evaluates whether the received status is terminal.
- Terminal — Closes the OM task (idempotent), sets
{nodeName}_resultat parent scope, subprocess exits. - Non-terminal — Sets
{nodeName}_resultat parent scope, subprocess exits. Parent-level edges route back to the node.
Status is set as a Flowable process variable when the subprocess exits. Parent-level edges condition on this variable. Non-terminal edges loop back to the node.
┌────── "ack" ───────────────────────────┐
│ ┌─── "assigned" → [NotifySlack] ──────┤
▼ ▼ │
[Start] → [ManageIncident] ── "resolved" → [End]
Workflow definition example:
{
"name": "incident-lifecycle",
"trigger": {
"type": "eventBasedEntity",
"config": {
"entityTypes": ["TestCase"],
"events": ["Updated"],
"filter": { "TestCase": { "==": [{"var": "testCaseStatus"}, "Failed"] } }
}
},
"nodes": [
{ "type": "startEvent", "name": "start" },
{ "type": "taskLifecycleNode", "name": "incident", "config": {
"template": "incident",
"statuses": ["New", "Ack", "Assigned", "Resolved"],
"terminal": ["Resolved"],
"responsibles": { "source": "tableOwner" },
"ttl": "P30D"
}},
{ "type": "automatedTask", "subType": "sinkTask", "name": "notifySlack" },
{ "type": "endEvent", "name": "end" }
],
"edges": [
{ "from": "start", "to": "incident" },
{ "from": "incident", "to": "incident", "condition": { "status": "Ack" } },
{ "from": "incident", "to": "notifySlack", "condition": { "status": "Assigned" } },
{ "from": "notifySlack", "to": "incident" },
{ "from": "incident", "to": "end", "condition": { "status": "Resolved" } }
]
}With the task refactor, tasks emit ChangeEvents on status changes. These drive message delivery to Flowable:
- Task status changes (via REST API /
storeInternal) ChangeEventemitted- Listener correlates message to waiting
IntermediateCatchEvent
The OM task is already updated before the message fires. If correlation fails, the task state is correct — Flowable catches up on the next status change.
Mechanism TBD: Listener on task ChangeEvents (clean separation) vs direct hook in task status update code (fewer hops).
| Action | Who handles it |
|---|---|
| Task creation | Node setup phase (idempotent) |
| Status changes (Ack, Assigned, etc.) | Repository — synchronous, unchanged |
| Resolution | Repository — synchronous, unchanged |
| Task closure | Both — node closes on terminal, repository may also close. Idempotent. |
| Flowable notification | Task ChangeEvent → message to IntermediateCatchEvent |
| Follow-up hooks | Workflow edges — user-configurable |
| TTL auto-resolve | Boundary timer on node |
| Auto-close on test pass | Separate short-lived workflow |
- Hooks on any transition. Status exposed to parent graph → users wire follow-up steps via edges.
- Configurable lifecycle. Template defines statuses and terminal set. No hardcoded lifecycle.
- OM task is source of truth. No BPMN UserTask. ~2 DB rows per task vs ~5-10.
- Repository stays in the critical path. All transitions are synchronous. Flowable is notified after the fact. If Flowable is down, transitions still succeed.
- Unified abstraction. Same node type for incidents, approvals, certifications — different templates.
- Hooks on any status transition without code changes.
- Configurable lifecycle from day one via template config.
- Lightweight — ~2 Flowable DB rows per task (IntermediateCatchEvent).
- Safe — repository owns all transitions synchronously; Flowable is follow-up only.
- Default workflow replicates current behavior and ships enabled.
- Unified abstraction — incidents, approvals, certifications share one node type.
- MainWorkflow compiler must support cycles. Today it assumes a DAG. Biggest technical risk.
- More Flowable interactions. Every status change sends a message (vs resolution only). ~225K correlations over lifetime of 75K incidents with ~3 transitions each.
- Task refactor dependency. Fallback: direct
reportOutcome()fromstoreInternal()if not ready.
- REST API surface unchanged.
TestCaseResolutionStatusschema changes minimally (addAutoResolved,Expiredreasons).- Resolution business logic in the repository is unchanged.
Handle only creation + resolution in the workflow. Intermediate states stay entirely in storeInternal().
Not chosen: Users cannot wire hooks on Ack/Assigned. The task refactor makes full lifecycle hooks possible now — deferring them means two migrations.
Internal loop (cycle hidden inside SubProcess)
The message loop lives inside the node. Status exposed only on terminal exit. Outer graph stays a DAG.
Not chosen: Users cannot wire hooks on non-terminal transitions. The point is exposing every status change to the parent graph.
Route resolution through the Flowable process.
Not chosen: Puts Flowable in the critical path. If Flowable is slow/down, resolution is blocked.
Rejected: Parallel automation system, requires code changes for every new behavior.
Rejected: Zero existing infrastructure, overkill.
messageExpression="${omTaskId}" gives unique-per-instance subscriptions. EventSubscriptionQuery.eventName(taskId) is an indexed lookup. No MessageCorrelationBuilder (doesn't exist in Flowable 7.2.0).
When non-terminal edges loop back, Setup detects the existing task and reuses it. Safe for any number of loops.
storeInternal(Resolved) closes the task. The node's CloseTask also closes on terminal status. Both are idempotent. This handles TTL (node-initiated) and human resolution (repository-initiated) uniformly.
Enables idempotent creation, fire-and-forget termination, auto-close correlation.
WorkflowEventConsumer skips events from governance-bot. The workflow runs as governance-bot, so its own events don't re-trigger workflows.
- Message delivery mechanism: Listener on task ChangeEvents vs direct hook in task status update.
- TestCaseResult.incidentId linking: If creation moves to async workflow, test result may store before incident exists. Recommendation: keep
getOrCreateIncident()synchronous. - Cycle validation: Should the compiler enforce that every non-terminal edge path routes back to a task node?
| Risk | Impact | Mitigation |
|---|---|---|
| Cycle support in MainWorkflow | Blocks the design | Spike early. Workaround: invisible gateway node. |
| Task refactor not ready | No ChangeEvents for message delivery | Fall back to direct reportOutcome() from storeInternal() |
| Race condition | Message lost during follow-up execution | EventSubscriptionQuery returns null → skipped. Java-side buffer later. |
| ACT_RU growth | ~2 rows per open incident | 75K incidents = 150K rows. Measure in hardening phase. |
| Process orphaning | Never-resolved incidents linger | TTL handles deadlines. Batch sweep for the rest. |
- Batch sweep for orphaned processes.
- Migrate UserApprovalTask (glossary) to same node type with
template: "approval". - SLA timer escalation — optional boundary timer using same infrastructure as TTL.
TestCaseResolutionStatusRepository.storeInternal()— Current state machineWorkflowHandler.java— Flowable ProcessEngine, message deliveryMainWorkflow.java— BPMN compiler (needs cycle support)UserApprovalTask.java— Current UserTask pattern (being replaced)NodeFactory.java— Node type registrationWorkflowEventConsumer.java— Event routing, governance-bot loop prevention