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.