Rules Engine + User-Task Forms — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship P3.5 (event-condition-action rules engine) and P2.3 (user-task form rendering in the SPA) to complete the v1.0 Tier 1 customization story.
Architecture: Rules are stored as metadata__rule rows (JSONB payload). A RuleEngine service loads enabled rules at boot, subscribes to the event bus by topic, evaluates conditions against event properties, and executes actions. User-task forms are rendered by the existing MetadataFormRenderer, fed by new task-list/complete endpoints on WorkflowController that wrap Flowable's TaskService.
Tech Stack: Kotlin/Spring Boot (backend), Flowable TaskService (user-tasks), Jackson (event-to-map serialization for condition eval), React + TypeScript (SPA pages)
Spec: docs/superpowers/specs/2026-04-10-rules-engine-usertask-forms-design.md
File Map
New files (Backend)
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.ktplatform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.ktplatform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.ktplatform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt
New files (Frontend)
web/src/pages/UserTasksPage.tsxweb/src/pages/TaskDetailPage.tsx
Modified files
-
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt— add RuleYaml -
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt— process rules -
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt— add task endpoints -
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt— add task methods -
web/src/pages/MetadataAdminPage.tsx— add Rules tab -
web/src/api/client.ts— add workflow task + rule API functions -
web/src/types/api.ts— add UserTask + Rule types -
web/src/i18n/messages.ts— add keys -
web/src/App.tsx— add routes -
web/src/layout/AppLayout.tsx— add nav entry -
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt— add workflow routes
Task 1: Add user-task endpoints to WorkflowController
Files:
- Modify:
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt Modify:
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.ktStep 1: Add task methods to WorkflowService
Add three methods using Flowable's TaskService:
fun listPendingTasks(): List<UserTaskSummary> {
return taskService.createTaskQuery()
.active()
.orderByTaskCreateTime().desc()
.list()
.map { task ->
UserTaskSummary(
taskId = task.id,
taskName = task.name ?: task.taskDefinitionKey,
formKey = task.formKey,
processDefinitionKey = repositoryService.getProcessDefinition(task.processDefinitionId).key,
processInstanceId = task.processInstanceId,
createTime = task.createTime.toInstant(),
assignee = task.assignee,
)
}
}
fun getTaskDetail(taskId: String): UserTaskDetail {
val task = taskService.createTaskQuery().taskId(taskId).singleResult()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found")
val vars = runtimeService.getVariables(task.executionId)
.filterKeys { !it.startsWith("__vibeerp_") }
return UserTaskDetail(
taskId = task.id,
taskName = task.name ?: task.taskDefinitionKey,
formKey = task.formKey,
variables = vars,
processDefinitionKey = repositoryService.getProcessDefinition(task.processDefinitionId).key,
processInstanceId = task.processInstanceId,
createTime = task.createTime.toInstant(),
)
}
fun completeTask(taskId: String, variables: Map<String, Any?>) {
val task = taskService.createTaskQuery().taskId(taskId).singleResult()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found")
taskService.complete(taskId, variables.filterValues { it != null })
}
Data classes:
data class UserTaskSummary(
val taskId: String,
val taskName: String,
val formKey: String?,
val processDefinitionKey: String,
val processInstanceId: String,
val createTime: Instant,
val assignee: String?,
)
data class UserTaskDetail(
val taskId: String,
val taskName: String,
val formKey: String?,
val variables: Map<String, Any?>,
val processDefinitionKey: String,
val processInstanceId: String,
val createTime: Instant,
)
- Step 2: Add endpoints to WorkflowController
@GetMapping("/tasks")
@RequirePermission("workflow.task.read")
fun listTasks(): List<UserTaskSummary> = workflowService.listPendingTasks()
@GetMapping("/tasks/{taskId}")
@RequirePermission("workflow.task.read")
fun getTask(@PathVariable taskId: String): UserTaskDetail = workflowService.getTaskDetail(taskId)
@PostMapping("/tasks/{taskId}/complete")
@RequirePermission("workflow.task.complete")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun completeTask(@PathVariable taskId: String, @RequestBody variables: Map<String, Any?>) {
workflowService.completeTask(taskId, variables)
}
- Step 3: Add workflow.task.read and workflow.task.complete permissions
Add to the existing platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml (or create it if it doesn't exist):
permissions:
- key: workflow.task.read
description: View pending user tasks
- key: workflow.task.complete
description: Complete a pending user task
- Step 4: Build, test, commit
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
git commit -m "feat(workflow): user-task list/detail/complete endpoints"
Task 2: Add RuleYaml + MetadataLoader extension for rules
Files:
- Modify:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt Modify:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.ktStep 1: Add RuleYaml data classes to MetadataYaml.kt
@JsonIgnoreProperties(ignoreUnknown = true)
data class RuleYaml(
val slug: String = "",
val name: String = "",
val description: String? = null,
val enabled: Boolean = true,
val triggerEvent: String = "",
val conditions: List<RuleConditionYaml> = emptyList(),
val conditionLogic: String = "AND",
val actions: List<RuleActionYaml> = emptyList(),
val version: Int = 1,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RuleConditionYaml(
val field: String = "",
val operator: String = "eq",
val value: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RuleActionYaml(
val type: String = "log",
val config: Map<String, String> = emptyMap(),
)
Add rules: List<RuleYaml> = emptyList() to MetadataYamlFile.
- Step 2: Extend MetadataLoader
Add DELETE FROM metadata__rule WHERE source = :source to wipeBySource().
Add insertRules() method following the same pattern.
Call from doLoad(). Add ruleCount to LoadResult.
- Step 3: Add tests, build, commit
Add YAML parse test for rules section. Run tests.
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test
git commit -m "feat(metadata): extend YAML schema + loader for rules"
Task 3: Build RuleEvaluator + RuleEngine + RuleController
Files:
- Create:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt - Create:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt - Create:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt Create:
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.ktStep 1: Create RuleEvaluator (pure function, no dependencies)
package org.vibeerp.platform.metadata.rules
object RuleEvaluator {
fun evaluate(eventMap: Map<String, Any?>, conditions: List<Condition>, logic: String): Boolean {
if (conditions.isEmpty()) return true
return if (logic == "OR") {
conditions.any { matches(eventMap, it) }
} else {
conditions.all { matches(eventMap, it) }
}
}
private fun matches(eventMap: Map<String, Any?>, c: Condition): Boolean {
val actual = eventMap[c.field] ?: return c.operator == "eq" && c.value.isBlank()
val av = actual.toString()
return when (c.operator) {
"eq" -> av == c.value
"neq" -> av != c.value
"gt" -> av.toDoubleOrNull()?.let { it > c.value.toDouble() } ?: false
"gte" -> av.toDoubleOrNull()?.let { it >= c.value.toDouble() } ?: false
"lt" -> av.toDoubleOrNull()?.let { it < c.value.toDouble() } ?: false
"lte" -> av.toDoubleOrNull()?.let { it <= c.value.toDouble() } ?: false
"contains" -> av.contains(c.value, ignoreCase = true)
"in" -> c.value.split(",").map { it.trim() }.contains(av)
else -> false
}
}
data class Condition(val field: String, val operator: String, val value: String)
}
- Step 2: Write RuleEvaluator tests
Test cases: eq match/mismatch, numeric gt/lt/gte/lte, contains, in, AND logic, OR logic, empty conditions returns true, null field handling.
- Step 3: Create RuleEngine
@Component
class RuleEngine(
private val eventBus: EventBus,
private val jdbc: NamedParameterJdbcTemplate,
private val objectMapper: ObjectMapper,
) {
private val subscriptions = mutableListOf<EventBus.Subscription>()
private val log = LoggerFactory.getLogger(RuleEngine::class.java)
@PostConstruct
fun loadAndSubscribe() { refresh() }
fun refresh() {
subscriptions.forEach { it.close() }
subscriptions.clear()
val rules = loadEnabledRules()
val byTopic = rules.groupBy { it.triggerTopic }
byTopic.forEach { (topic, topicRules) ->
val sub = eventBus.subscribe(topic) { event ->
val eventMap = objectMapper.convertValue(event, Map::class.java) as Map<String, Any?>
topicRules.forEach { rule ->
val conditions = rule.conditions.map { RuleEvaluator.Condition(it.field, it.operator, it.value) }
if (RuleEvaluator.evaluate(eventMap, conditions, rule.conditionLogic)) {
executeActions(rule, event, eventMap)
}
}
}
subscriptions.add(sub)
}
log.info("RuleEngine: loaded {} enabled rules across {} topics", rules.size, byTopic.size)
}
private fun executeActions(rule: LoadedRule, event: DomainEvent, eventMap: Map<String, Any?>) {
rule.actions.forEach { action ->
when (action.type) {
"log" -> {
val msg = action.config["message"] ?: "Rule '${rule.name}' fired"
log.info("[RULE:{}] {}", rule.slug, msg)
}
else -> log.warn("[RULE:{}] unknown action type '{}'", rule.slug, action.type)
}
}
}
private fun loadEnabledRules(): List<LoadedRule> { /* read from metadata__rule, filter enabled */ }
}
- Step 4: Create RuleController (CRUD with source='user' enforcement)
Same pattern as FormDefinitionController — PUT/DELETE on metadata__rule table with source='user' enforcement. GET endpoints for listing and by-slug lookup. After every write, call ruleEngine.refresh().
Add read endpoints to MetadataController: GET /rules, GET /rules/{slug}.
Add "rules" to the all() response map.
- Step 5: Run tests, build, commit
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
git commit -m "feat(metadata): rules engine — event-condition-action automation"
Task 4: Build SPA pages for user-tasks and rules
Files:
- Create:
web/src/pages/UserTasksPage.tsx - Create:
web/src/pages/TaskDetailPage.tsx - Modify:
web/src/pages/MetadataAdminPage.tsx— add Rules tab - Modify:
web/src/api/client.ts— add API functions - Modify:
web/src/types/api.ts— add types - Modify:
web/src/i18n/messages.ts— add keys - Modify:
web/src/App.tsx— add routes - Modify:
web/src/layout/AppLayout.tsx— add nav entry Modify:
platform/platform-bootstrap/.../SpaController.kt— add workflow route prefixesStep 1: Add types to api.ts
export interface UserTaskSummary {
taskId: string
taskName: string
formKey: string | null
processDefinitionKey: string
processInstanceId: string
createTime: string
assignee: string | null
}
export interface UserTaskDetail extends UserTaskSummary {
variables: Record<string, unknown>
}
export interface RuleCondition {
field: string
operator: string
value: string
}
export interface RuleAction {
type: string
config: Record<string, string>
}
export interface RuleDefinition {
slug: string
name: string
description?: string
enabled: boolean
triggerEvent: string
conditions: RuleCondition[]
conditionLogic: 'AND' | 'OR'
actions: RuleAction[]
version: number
source?: string
}
- Step 2: Add API client functions
export const workflow = {
listTasks: () => apiFetch<UserTaskSummary[]>('/api/v1/workflow/tasks'),
getTask: (taskId: string) => apiFetch<UserTaskDetail>(`/api/v1/workflow/tasks/${taskId}`),
completeTask: (taskId: string, variables: Record<string, unknown>) =>
apiFetch<void>(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false),
}
// Add to metadata namespace:
listRules: () => apiFetch<RuleDefinition[]>('/api/v1/_meta/metadata/rules'),
getRule: (slug: string) => apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`),
saveRule: (slug: string, body: Omit<RuleDefinition, 'source'>) =>
apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteRule: (slug: string) =>
apiFetch<void>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'DELETE' }, false),
- Step 3: Add i18n keys
English + Chinese keys for: nav.tasks, page.tasks.title, tab.rules, label.triggerEvent, label.conditions, label.actions, label.enabled, action.complete, action.newRule, etc.
- Step 4: Create UserTasksPage
DataTable listing pending tasks. Columns: Task Name, Process, Created, Assignee. Click row navigates to /workflow/tasks/{taskId}.
- Step 5: Create TaskDetailPage
Fetches task detail. If formKey starts with vibe:, strips prefix and renders <MetadataFormRenderer slug={slug} initialValues={variables} onSubmit={completeTask} />. If no formKey, shows a simple "Complete Task" button.
- Step 6: Add Rules tab to MetadataAdminPage
7th tab showing rules in a DataTable (Name, Trigger Event, Enabled toggle, Conditions count, Actions count, Source badge). Inline form for creating/editing rules with: name, trigger event dropdown, conditions builder (field/operator/value rows), condition logic toggle (AND/OR), actions builder (type/config rows), enabled checkbox.
Known event types for the dropdown: SalesOrderConfirmedEvent, SalesOrderShippedEvent, SalesOrderCancelledEvent, PurchaseOrderConfirmedEvent, PurchaseOrderReceivedEvent, PurchaseOrderCancelledEvent, WorkOrderCreatedEvent, WorkOrderStartedEvent, WorkOrderCompletedEvent, WorkOrderCancelledEvent, InspectionRecordedEvent.
- Step 7: Add routes, nav, SpaController
Routes: /workflow/tasks, /workflow/tasks/:taskId
Nav: "Tasks" under a "Workflow" section
SpaController: add /workflow/** prefix
- Step 8: Build and commit
cd web && npm run build && cd ..
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
git commit -m "feat(web): user-task pages + rules tab in metadata admin"
Task 5: Smoke test + version bump + push
- Step 1: Full build
- Step 2: Boot and smoke test
- Verify Tasks page loads (empty if no running processes)
- Start a plate-approval process, verify the user-task appears
- Complete the task with the form, verify it disappears
- Create a rule via Metadata Admin → Rules tab
- Trigger the rule's event, verify log output
- Step 3: Bump version to v0.34.0-SNAPSHOT
- Step 4: Update PROGRESS.md (P2.3 + P3.5 done)
- Step 5: Update CLAUDE.md test count
- Step 6: Commit, push, verify CI