2026-04-10-rules-engine-usertask-forms.md 17 KB

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.kt
  • platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt
  • platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt
  • platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt

New files (Frontend)

  • web/src/pages/UserTasksPage.tsx
  • web/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.kt

  • Step 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.kt

  • Step 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.kt

  • Step 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 prefixes

  • Step 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