# 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`: ```kotlin fun listPendingTasks(): List { 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) { 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: ```kotlin 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, val processDefinitionKey: String, val processInstanceId: String, val createTime: Instant, ) ``` - [ ] **Step 2: Add endpoints to WorkflowController** ```kotlin @GetMapping("/tasks") @RequirePermission("workflow.task.read") fun listTasks(): List = 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) { 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): ```yaml 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** ```bash 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** ```kotlin @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 = emptyList(), val conditionLogic: String = "AND", val actions: List = 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 = emptyMap(), ) ``` Add `rules: List = 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. ```bash 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)** ```kotlin package org.vibeerp.platform.metadata.rules object RuleEvaluator { fun evaluate(eventMap: Map, conditions: List, 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, 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** ```kotlin @Component class RuleEngine( private val eventBus: EventBus, private val jdbc: NamedParameterJdbcTemplate, private val objectMapper: ObjectMapper, ) { private val subscriptions = mutableListOf() 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 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) { 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 { /* 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** ```bash 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** ```typescript 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 } export interface RuleCondition { field: string operator: string value: string } export interface RuleAction { type: string config: Record } 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** ```typescript export const workflow = { listTasks: () => apiFetch('/api/v1/workflow/tasks'), getTask: (taskId: string) => apiFetch(`/api/v1/workflow/tasks/${taskId}`), completeTask: (taskId: string, variables: Record) => apiFetch(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false), } // Add to metadata namespace: listRules: () => apiFetch('/api/v1/_meta/metadata/rules'), getRule: (slug: string) => apiFetch(`/api/v1/_meta/metadata/rules/${slug}`), saveRule: (slug: string, body: Omit) => apiFetch(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), deleteRule: (slug: string) => apiFetch(`/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 ``. 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** ```bash 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**