-
New platform subproject `platform/platform-workflow` that makes `org.vibeerp.api.v1.workflow.TaskHandler` a live extension point. This is the framework's first chunk of Phase 2 (embedded workflow engine) and the dependency other work has been waiting on — pbc-production routings/operations, the full buy-make-sell BPMN scenario in the reference plug-in, and ultimately the BPMN designer web UI all hang off this seam. ## The shape - `flowable-spring-boot-starter-process:7.0.1` pulled in behind a single new module. Every other module in the framework still sees only the api.v1 TaskHandler + WorkflowTask + TaskContext surface — guardrail #10 stays honest, no Flowable type leaks to plug-ins or PBCs. - `TaskHandlerRegistry` is the host-side index of every registered handler, keyed by `TaskHandler.key()`. Auto-populated from every Spring bean implementing TaskHandler via constructor injection of `List<TaskHandler>`; duplicate keys fail fast at registration time. `register` / `unregister` exposed for a future plug-in lifecycle integration. - `DispatchingJavaDelegate` is a single Spring-managed JavaDelegate named `taskDispatcher`. Every BPMN service task in the framework references it via `flowable:delegateExpression="${taskDispatcher}"`. The dispatcher reads `execution.currentActivityId` as the task key (BPMN `id` attribute = TaskHandler key — no extension elements, no field injection, no second source of truth) and routes to the matching registered handler. A defensive copy of the execution variables is passed to the handler so it cannot mutate Flowable's internal map. - `DelegateTaskContext` adapts Flowable's `DelegateExecution` to the api.v1 `TaskContext` — the variable `set(name, value)` call forwards through Flowable's variable scope (persisted in the same transaction as the surrounding service task execution) and null values remove the variable. Principal + locale are documented placeholders for now (a workflow-engine `Principal.System`), waiting on the propagation chunk that plumbs the initiating user through `runtimeService.startProcessInstanceByKey(...)`. - `WorkflowService` is a thin facade over Flowable's `RuntimeService` + `RepositoryService` exposing exactly the four operations the controller needs: start, list active, inspect variables, list definitions. Everything richer (signals, timers, sub-processes, user-task completion, history queries) lands on this seam in later chunks. - `WorkflowController` at `/api/v1/workflow/**`: * `POST /process-instances` (permission `workflow.process.start`) * `GET /process-instances` (`workflow.process.read`) * `GET /process-instances/{id}/variables` (`workflow.process.read`) * `GET /definitions` (`workflow.definition.read`) * `GET /handlers` (`workflow.definition.read`) Exception handlers map `NoSuchElementException` + `FlowableObjectNotFoundException` → 404, `IllegalArgumentException` → 400, and any other `FlowableException` → 400. Permissions are declared in a new `META-INF/vibe-erp/metadata/workflow.yml` loaded by the core MetadataLoader so they show up under `GET /api/v1/_meta/metadata` alongside every other permission. ## The executable self-test - `vibeerp-ping.bpmn20.xml` ships in `processes/` on the module classpath and Flowable's starter auto-deploys it at boot. Structure: `start` → serviceTask id=`vibeerp.workflow.ping` (delegateExpression=`${taskDispatcher}`) → `end`. Process definitionKey is `vibeerp-workflow-ping` (distinct from the serviceTask id because BPMN 2.0 ids must be unique per document). - `PingTaskHandler` is a real shipped bean, not test code: its `execute` writes `pong=true`, `pongAt=<Instant.now()>`, and `correlationId=<ctx.correlationId()>` to the process variables. Operators and AI agents get a trivial "is the workflow engine alive?" probe out of the box. Why the demo lives in src/main, not src/test: Flowable's auto-deployer reads from the host classpath at boot, so if either half lived under src/test the smoke test wouldn't be reproducible from the shipped image — exactly what CLAUDE.md's "reference plug-in is the executable acceptance test" discipline is trying to prevent. ## The Flowable + Liquibase trap **Learned the hard way during the smoke test.** Adding `flowable-spring-boot-starter-process` immediately broke boot with `Schema-validation: missing table [catalog__item]`. Liquibase was silently not running. Root cause: Flowable 7.x registers a Spring Boot `EnvironmentPostProcessor` called `FlowableLiquibaseEnvironmentPostProcessor` that, unless the user has already set an explicit value, forces `spring.liquibase.enabled=false` with a WARN log line that reads "Flowable pulls in Liquibase but does not use the Spring Boot configuration for it". Our master.xml then never executes and JPA validation fails against the empty schema. Fix is a single line in `distribution/src/main/resources/application.yaml` — `spring.liquibase.enabled: true` — with a comment explaining why it must stay there for anyone who touches config next. Flowable's own ACT_* tables and vibe_erp's `catalog__*`, `pbc.*__*`, etc. tables coexist happily in the same public schema — 39 ACT_* tables alongside 45 vibe_erp tables on the smoke-tested DB. Flowable manages its own schema via its internal MyBatis DDL, Liquibase manages ours, they don't touch each other. ## Smoke-test transcript (fresh DB, dev profile) ``` docker compose down -v && docker compose up -d db ./gradlew :distribution:bootRun & # ... Flowable creates ACT_* tables, Liquibase creates vibe_erp tables, # MetadataLoader loads workflow.yml, TaskHandlerRegistry boots with 1 handler, # BPMN auto-deployed from classpath POST /api/v1/auth/login → JWT GET /api/v1/workflow/definitions → 1 definition (vibeerp-workflow-ping) GET /api/v1/workflow/handlers → {"count":1,"keys":["vibeerp.workflow.ping"]} POST /api/v1/workflow/process-instances {"processDefinitionKey":"vibeerp-workflow-ping", "businessKey":"smoke-1", "variables":{"greeting":"ni hao"}} → 201 {"processInstanceId":"...","ended":true, "variables":{"pong":true,"pongAt":"2026-04-09T...", "correlationId":"...","greeting":"ni hao"}} POST /api/v1/workflow/process-instances {"processDefinitionKey":"does-not-exist"} → 404 {"message":"No process definition found for key 'does-not-exist'"} GET /api/v1/catalog/uoms → still returns the 15 seeded UoMs (sanity) ``` ## Tests - 15 new unit tests in `platform-workflow/src/test`: * `TaskHandlerRegistryTest` — init with initial handlers, duplicate key fails fast, blank key rejected, unregister removes, unregister on unknown returns false, find on missing returns null * `DispatchingJavaDelegateTest` — dispatches by currentActivityId, throws on missing handler, defensive-copies the variable map * `DelegateTaskContextTest` — set non-null forwards, set null removes, blank name rejected, principal/locale/correlationId passthrough, default correlation id is stable across calls * `PingTaskHandlerTest` — key matches the BPMN serviceTask id, execute writes pong + pongAt + correlationId - Total framework unit tests: 261 (was 246), all green. ## What this unblocks - **REF.1** — real quote→job-card workflow handler in the printing-shop plug-in - **pbc-production routings/operations (v3)** — each operation becomes a BPMN step with duration + machine assignment - **P2.3** — user-task form rendering (landing on top of the RuntimeService already exposed via WorkflowService) - **P2.2** — BPMN designer web page (later, depends on R1) ## Deliberate non-goals (parking lot) - Principal propagation from the REST caller through the process start into the handler — uses a fixed `workflow-engine` `Principal.System` for now. Follow-up chunk will plumb the authenticated user as a Flowable variable. - Plug-in-contributed TaskHandler registration via PF4J child contexts — the registry exposes `register/unregister` but the plug-in loader doesn't call them yet. Follow-up chunk. - BPMN user tasks, signals, timers, history queries — seam exists, deliberately not built out. - Workflow deployment from `metadata__workflow` rows (the Tier 1 path). Today deployment is classpath-only via Flowable's auto- deployer. - The Flowable async job executor is explicitly deactivated (`flowable.async-executor-activate: false`) — background-job machinery belongs to the future Quartz integration (P1.10), not Flowable.