2026-04-10-rules-engine-usertask-forms-design.md 9.54 KB

Rules Engine + User-Task Form Rendering — Design Spec

Sub-projects B+C of the v1.0 remaining work. Covers P3.5 (rules engine) and P2.3 (user-task form rendering). P2.2 (BPMN designer) is deferred to v1.1.

1. Context

With P3.2/P3.3/P3.6/R3 shipped, the framework has metadata-driven forms, a form designer, and a metadata admin UI. Two pieces remain for v1.0:

  1. P2.3 — User-task form rendering: Flowable user-tasks need a SPA page where operators can see pending tasks and complete them by filling out metadata-driven forms (using the MetadataFormRenderer we just built).

  2. P3.5 — Rules engine: Key users need to create "if event X happens and condition Y is met, then do action Z" rules through the metadata admin UI, without writing code. Rules are stored as metadata__rule rows.

2. P2.3 — User-Task Form Rendering

New backend endpoints

Add to WorkflowController:

GET /api/v1/workflow/tasks
  → List pending user-tasks for the current user (or all if admin)
  Returns: List<UserTaskSummary> with taskId, taskName, formKey,
           processDefinitionKey, processInstanceId, createTime, assignee

GET /api/v1/workflow/tasks/{taskId}
  → Get task details + variables
  Returns: UserTaskDetail with taskId, taskName, formKey, variables,
           processDefinitionKey, processInstanceId, createTime

POST /api/v1/workflow/tasks/{taskId}/complete
  → Complete the task with form values
  Request body: Map<String, Any?> (the submitted form data)
  Returns: 204 No Content

These use Flowable's TaskService.createTaskQuery() and TaskService.complete(taskId, variables).

The formKey on the user-task (set in BPMN XML as flowable:formKey="vibe:plate-approval-task") maps to a form definition slug in metadata__form. The SPA strips the vibe: prefix and fetches the form via the existing GET /api/v1/_meta/metadata/forms/{slug} endpoint.

New SPA page: UserTasksPage

Route: /workflow/tasks

  • Lists pending user-tasks in a DataTable (task name, process, created time, assignee)
  • Click a task → expands inline or navigates to /workflow/tasks/{taskId}
  • TaskDetailPage renders the form using <MetadataFormRenderer slug={formKey} /> with initialValues from the task variables
  • Submit button calls POST /api/v1/workflow/tasks/{taskId}/complete
  • On success, navigates back to the task list

SPA route: TaskDetailPage

Route: /workflow/tasks/:taskId

  • Fetches task detail (variables + formKey)
  • Strips vibe: prefix from formKey → slug
  • Renders <MetadataFormRenderer slug={slug} initialValues={variables} onSubmit={completeTask} />
  • If no formKey, shows a simple "Complete" button with no form

3. P3.5 — Rules Engine

Rule definition schema (stored in metadata__rule.payload)

interface RuleDefinition {
  slug: string              // unique identifier
  name: string              // display name
  description?: string
  enabled: boolean          // toggle on/off without deleting
  triggerEvent: string      // event class simple name: "SalesOrderConfirmedEvent"
  conditions: Array<{
    field: string           // event property path: "totalAmount"
    operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'in'
    value: string           // compared value (coerced to field type at eval time)
  }>
  conditionLogic: 'AND' | 'OR'  // how to combine multiple conditions
  actions: Array<{
    type: 'log' | 'set-field' | 'publish-event'
    config: Record<string, string>
  }>
  version: number
}

Built-in action types (v1.0)

Action type What it does Config
log Writes an INFO log line with a template message { message: "Order {orderCode} exceeded threshold" }
set-field Sets a field on the event's aggregate via a REST call or ext update { entity: "SalesOrder", field: "ext.priority", value: "HIGH" }
publish-event Publishes a synthetic event to the event bus { eventType: "RuleTriggeredEvent", data: "{...}" }

v1.0 keeps actions simple. Complex actions (send email, call webhook, create entity) are v1.1.

Backend: RuleEngine service

New class in platform-metadata (or a new platform-rules module — but YAGNI, put it in platform-metadata since rules are metadata):

@Component
class RuleEngine(
    private val eventBus: EventBus,
    private val jdbc: NamedParameterJdbcTemplate,
    private val objectMapper: ObjectMapper,
) {
    @PostConstruct
    fun loadAndSubscribe() {
        // 1. Read all enabled rules from metadata__rule
        // 2. Group by triggerEvent
        // 3. Subscribe to each distinct event type via eventBus.subscribe(topic, listener)
        //    using the aggregateType as the topic string
        // 4. When an event fires, evaluate conditions against event properties
        // 5. If conditions match, execute actions
    }

    fun refresh() {
        // Unsubscribe all, reload, resubscribe
        // Called after rule CRUD operations
    }
}

Condition evaluation

Events are domain objects with typed properties. The rule engine serializes the event to a Map<String, Any?> via Jackson, then evaluates each condition:

fun evaluateCondition(eventMap: Map<String, Any?>, condition: Condition): Boolean {
    val actual = eventMap[condition.field] ?: return false
    val expected = condition.value
    return when (condition.operator) {
        "eq" -> actual.toString() == expected
        "neq" -> actual.toString() != expected
        "gt" -> (actual as? Number)?.toDouble()?.let { it > expected.toDouble() } ?: false
        "gte" -> (actual as? Number)?.toDouble()?.let { it >= expected.toDouble() } ?: false
        "lt" -> (actual as? Number)?.toDouble()?.let { it < expected.toDouble() } ?: false
        "lte" -> (actual as? Number)?.toDouble()?.let { it <= expected.toDouble() } ?: false
        "contains" -> actual.toString().contains(expected)
        "in" -> expected.split(",").map { it.trim() }.contains(actual.toString())
        else -> false
    }
}

Backend endpoints (in MetadataController or new RuleController)

GET    /api/v1/_meta/metadata/rules          → List<RuleDefinition>
GET    /api/v1/_meta/metadata/rules/{slug}   → RuleDefinition
PUT    /api/v1/_meta/metadata/rules/{slug}   → RuleDefinition (upsert, source='user')
DELETE /api/v1/_meta/metadata/rules/{slug}   → 204

Same source='user' enforcement as forms/list-views. After every write, call ruleEngine.refresh().

SPA: Rules tab in MetadataAdmin

Add a 7th tab "Rules" to MetadataAdminPage:

  • DataTable showing: Name, Trigger Event, Enabled (toggle), Conditions (count), Actions (count), Source badge
  • "New Rule" button opens an inline form:
    • Name, description
    • Trigger event dropdown (populated from known event types)
    • Conditions builder: field, operator, value (add/remove rows)
    • Condition logic toggle (AND/OR)
    • Actions builder: type dropdown, config key-value inputs (add/remove rows)
    • Enabled checkbox
    • Save/Cancel
  • Edit button per source='user' row
  • Delete button per source='user' row

MetadataYaml extension

Add rules: List<RuleYaml> to MetadataYamlFile so core PBCs and plug-ins can ship default rules in their metadata YAML. Same loader/wipe pattern.

4. Scope Decisions

Decision Choice
BPMN designer (P2.2) Deferred to v1.1 — customers use Camunda Modeler or hand-edit BPMN XML
Rule action types v1.0: log, set-field, publish-event. v1.1: email, webhook, create entity
Rule condition complexity v1.0: flat field conditions with AND/OR. v1.1: nested groups, expressions
User-task assignment v1.0: all tasks visible to all authenticated users. v1.1: role-based assignment

5. File Inventory

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/main/kotlin/org/vibeerp/platform/metadata/yaml/RuleYaml.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-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
  • 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
  • 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

6. Testing

  • RuleEvaluatorTest.kt — unit tests for condition evaluation (eq, gt, lt, contains, in, AND/OR logic)
  • RuleEngine — integration: load rules from metadata, verify event subscription and action execution
  • Manual smoke: create a rule via UI, trigger the event, verify action fires
  • Manual smoke: start a BPMN process with a user-task, see it in the tasks page, complete it with the form