• Before this commit, every TaskHandler saw a fixed `workflow-engine`
    System principal via `ctx.principal()` because there was no plumbing
    from the REST caller down to the dispatcher. A printing-shop
    quote-to-job-card handler (or any real business workflow) needs to
    know the actual user who kicked off the process so audit columns and
    role-based logic behave correctly.
    
    ## Mechanism
    
    The chain is: Spring Security populates `SecurityContextHolder` →
    `PrincipalContextFilter` mirrors it into `AuthorizationContext`
    (already existed) → `WorkflowService.startProcess` reads the bound
    `AuthorizedPrincipal` and stashes two reserved process variables
    (`__vibeerp_initiator_id`, `__vibeerp_initiator_username`) before
    calling `RuntimeService.startProcessInstanceByKey` →
    `DispatchingJavaDelegate` reads them back off each `DelegateExecution`
    when constructing the `DelegateTaskContext` → handler sees a real
    `Principal.User` from `ctx.principal()`.
    
    When the process is started outside an HTTP request (e.g. a future
    Quartz-scheduled process, or a signal fired by a PBC event
    subscriber), `AuthorizationContext.current()` is null, no initiator
    variables are written, and the dispatcher falls back to the
    `Principal.System("workflow-engine")` principal. A corrupt initiator
    id (e.g. a non-UUID string) also falls back to the system principal
    rather than failing the task, so a stale variable can't brick a
    running workflow.
    
    ## Reserved variable hygiene
    
    The `__vibeerp_` prefix is reserved framework plumbing. Two
    consequences wired in this commit:
    
    - `DispatchingJavaDelegate` strips keys starting with `__vibeerp_`
      from the variable snapshot handed to the handler (via
      `WorkflowTask.variables`), so handler code cannot accidentally
      depend on the initiator id through the wrong door — it must use
      `ctx.principal()`.
    - `WorkflowService.startProcess` and `getInstanceVariables` strip
      the same prefix from their HTTP response payloads so REST callers
      never see the plumbing either.
    
    The prefix constant lives on `DispatchingJavaDelegate.RESERVED_VAR_PREFIX`
    so there is exactly one source of truth. The two initiator variable
    names are public constants on `WorkflowService` — tests, future
    plug-in code, and any custom handlers that genuinely need the raw
    ids (e.g. a security-audit task) can depend on the stable symbols
    instead of hard-coded strings.
    
    ## PingTaskHandler as the executable witness
    
    `PingTaskHandler` now writes a `pingedBy` output variable with a
    principal label (`user:<username>`, `system:<name>`, or
    `plugin:<pluginId>`) and logs it. That makes the end-to-end smoke
    test trivially assertable:
    
    ```
    POST /api/v1/workflow/process-instances
         {"processDefinitionKey":"vibeerp-workflow-ping"}
      (as admin user, with valid JWT)
      → {"processInstanceId": "...", "ended": true,
         "variables": {
           "pong": true,
           "pongAt": "...",
           "correlationId": "...",
           "pingedBy": "user:admin"
         }}
    ```
    
    Note the RESPONSE does NOT contain `__vibeerp_initiator_id` or
    `__vibeerp_initiator_username` — the reserved-var filter in the
    service layer hides them. The handler-side log line confirms
    `principal='user:admin'` in the service-task execution thread.
    
    ## Tests
    
    - 3 new tests in `DispatchingJavaDelegateTest`:
      * `resolveInitiator` returns a User principal when both vars set
      * falls back to system principal when id var is missing
      * falls back to system principal when id var is corrupt
        (non-UUID string)
    - Updated `variables given to the handler are a defensive copy` to
      also assert that reserved `__vibeerp_*` keys are stripped from
      the task's variable snapshot.
    - Updated `PingTaskHandlerTest`:
      * rename to "writes pong plus timestamp plus correlation id plus
        user principal label"
      * new test for the System-principal branch producing
        `pingedBy=system:workflow-engine`
    - Total framework unit tests: 265 (was 261), all green.
    
    ## Non-goals (still parking lot)
    
    - Plug-in-contributed TaskHandler registration via the PF4J loader
      walking child contexts for TaskHandler beans and calling
      `TaskHandlerRegistry.register`. The seam exists on the registry;
      the loader integration is the next chunk, and unblocks REF.1.
    - Propagation of the full role set (not just id+username) into the
      TaskContext. Handlers don't currently see the initiator's roles.
      Can be added as a third reserved variable when a handler actually
      needs it — YAGNI for now.
    - BPMN user tasks / signals / timers — engine supports them but we
      have no HTTP surface for them yet.
    zichun authored
     
    Browse Code »
  • 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.
    zichun authored
     
    Browse Code »