Skip to content

Reporting workflows and user tasks

Stephan Pelikan edited this page Oct 11, 2026 · 24 revisions

Your workflow module is connected, so the cockpit already shows your user tasks and your business cases. What it shows about them is what this page is for: the title, the columns, who may work on a task, and when somebody is notified about it.

You write that in your @WorkflowService class, next to the @WorkflowTask methods which do the work. Two annotations and one service are all there is.

  1. Describing one user task
    The method the cockpit calls, which task it belongs to, and what it may ask for.
  2. Describing a business case
    The same for a workflow, and who may see it.
  3. Business data in a list column
    The details map, what belongs in it, and what a column does with it.
  4. Organizing user tasks
    Assignee, candidates, dates and notifications.
  5. Who caused it: the initiator
    The one field you have to answer, and the two ways of answering it.
  6. Reporting a change from your own code
    BusinessCockpitService, for everything the engine never hears about.
  7. Reading back what was reported
    Getting the title the cockpit shows, so your own e-mail says the same.
  8. Who may see a workflow module
    The one bean which answers that.

Describing one user task

A user task is reported whether or not you write anything about it. The adapter reports every user task of every deployed process, with the name from the BPMN file as its title. A method of yours decides how much more the cockpit shows, never whether it shows the task at all.

@Service
@WorkflowService(workflowAggregateClass = Ride.class)
public class TaxiRide {

    @UserTaskDetailsProvider
    public UserTaskDetails retrievePayment(
            final Ride ride,
            final PrefilledUserTaskDetails details) {
        details.setDueDate(ride.getEndedAt().plus(2, ChronoUnit.HOURS));
        return details;
    }

}

The method is called every time the task changes. It runs at the moment of the event, in the transaction that event arrived in, so the aggregate it is handed is the case as the event left it. Where your own code reports a change through BusinessCockpitService, that is the case as you changed it, whether you saved it yet or not.

It is called when the task ends too, with COMPLETED or CANCELED. That is what makes the list of finished work show the business data a case was finished with rather than whatever the last change happened to say.

What the engine can tell you about the task at that moment differs per BPMS, and the wiki of your cockpit adapter says what yours answers. Where it can answer nothing at all, the end is reported anyway, without business data, and the cockpit keeps what the last change told it.

Which task the method belongs to follows VanillaBP's own convention: the method's name is the key, and it is matched against the task definition and against the BPMN element id. Where the name does not fit, say so:

@UserTaskDetailsProvider(taskDefinition = "RETRIEVE_PAYMENT")
public UserTaskDetails retrievePayment(...)

id names the BPMN element id instead. Write one of the two on an occurrence, not both. The annotation may be repeated, which is how one method serves several tasks:

@UserTaskDetailsProvider(taskDefinition = "RISK_ASSESSMENT")
@UserTaskDetailsProvider(taskDefinition = "OUT_OF_STOCK_MANAGEMENT")
public UserTaskDetails orderDetails(...)

One method can serve every user task instead of one. Write UserTaskDetailsProvider.ALL, which is "*", in place of a name:

@UserTaskDetailsProvider(taskDefinition = UserTaskDetailsProvider.ALL)
public UserTaskDetails everyUserTask(
        final Ride ride,
        final PrefilledUserTaskDetails details) {
    details.setDetails(Map.of("passenger", ride.getPassenger()));
    return details;
}

That method serves the user tasks of every BPMN process the @WorkflowService declares, the secondary ones included. The columns every task of your module shows are written once this way, and id = UserTaskDetailsProvider.ALL says the same thing.

Which method a task is served by, in this order:

  1. the method naming the BPMN element id, by id or by being called like it,
  2. the method naming the task definition, by taskDefinition or by being called like it,
  3. the method writing *.

So a method for one task and the method for every task stand next to each other, and the one for the task wins. Where two methods name the same task, one by its element id and one by its task definition, the element id wins.

That order says where VanillaBP is going. Every BPMN file has element ids, while a task definition is an extra attribute which each BPMS reads its own way. So write id where you have the choice. A method named that way keeps its task as VanillaBP moves away from the task definition.

Two methods writing the same name are a different matter: the application does not start and the message names both. The same happens for two methods writing *. Two methods share a name only by serving different versions of your model, which the next section describes.

Which versions of your model a method serves

The version attribute says which versions of the deployed BPMN process a method is asked for. A method which writes no version serves every version, so a workflow service which never thought about versions reports what it always reported.

@UserTaskDetailsProvider(id = "ApproveRide", version = "1-6")
public UserTaskDetails approveRideUpToSix(
        final Ride ride,
        final PrefilledUserTaskDetails details) {
    details.setDetails(Map.of("passenger", ride.getPassenger()));
    return details;
}

@UserTaskDetailsProvider(id = "ApproveRide", version = ">6")
public UserTaskDetails approveRideFromSeven(
        final Ride ride,
        final PrefilledUserTaskDetails details) {
    details.setDetails(Map.of("passenger", ride.getPassenger(), "seats", ride.getSeats()));
    return details;
}

A version is the version of the process definition as the BPMS counts it, or a version tag written into the model (camunda:versionTag in Camunda 7, zeebe:versionTag in Camunda 8). So a boundary is 3 or release-2024 for one version, 1-3 or v1.0..v2.0 for a range with both ends included, and >3 or <v2.0 for an open end. A range separates its ends by - or by .., and a boundary naming a tag which carries a - has to use ... @WorkflowTask uses the same spelling, and it means the same here, because VanillaBP picks the method the same way for both.

So two methods may name the same task after all, as long as the versions they serve do not overlap. That is how one generation of your model gets a method of its own. Overlapping ones end the start of your application, and the message names both.

A method which names no version does not take the range of its @BpmnProcess, and that is where it differs from a @WorkflowTask method. The range of the process says which generation of a model your workflow runs, and reporting to the cockpit is not running the workflow.

How far this goes depends on what the BPMS reports. Camunda 7 and Camunda 8 report the version of every task. An engine behind the Process-Engine-API reports a version tag where it writes one and nothing otherwise. Where no version arrives, only the methods which name no version run. A method which names one is reported while your application starts, so you do not wait for an event which never reaches it. That report is a warning and not the end of the start, because a version which is not deployed yet is what a rolling deployment looks like for a while.

The version your application deployed while it starts is a different case. Say a user task has providers, and none of them covers the version the BPMS gave to the model this start deployed. Then the start ends, the same as it does for a @WorkflowTask method. Without that, every report about the task would go out without its details, and nothing would tell you why. The message names the task, the version and every provider with its range, and it offers three fixes: widen a range, add a method for that version, or deploy the model the method was written for. The same holds for a process and its @WorkflowDetailsProvider methods. A task or a process with no provider at all is not asked about, because providers are optional.

The version of a task provider is always the version of the BPMN model the task belongs to. For a task of a called process (a call activity) that is the called process, both at the start and at every event. A called process which runs on the aggregate of its caller (secondaryBpmnProcesses) gets no workflow report of its own, so the version of its model is never held against a @WorkflowDetailsProvider. Camunda 7 and Camunda 8 report the version they deployed, so the check runs there. The Process-Engine-API reports none, so it does not run there.

What your own BPMS delivers as the version is written in its adapter wiki, for Camunda 7, Camunda 8 and the Process-Engine-API.

Version 1 documented this attribute and never read it, so a method which carried one served every version all the same. The attribute picks the method now, which is what an application writing it back then believed it had.

What the method may ask for

The parameters are whatever you declare, in any order. These are recognized:

Parameter What you get
PrefilledUserTaskDetails The details the adapter already filled in, to change and return
your workflow aggregate The aggregate of the case this task belongs to, to read
@TaskParam("name") ... One variable the task can see
@DetailsEvent DetailsEvent.Event Which lifecycle event caused this call: CREATED, UPDATED, COMPLETED or CANCELED
@MultiInstanceElement ... The multi-instance context, where the task is part of one

The prefilled object also carries what you cannot set: the task's id, the BPMN process id and version, the BPMN element id, and the id and timestamp of the event. Return it, or return anything else implementing UserTaskDetails; every value which is not null replaces what the adapter filled in.

Returning nothing of your own is fine. Where no method matches a task, the prefilled details are sent unchanged.

The aggregate is there to be read

Your method is asked what the cockpit should show. It is not asked to change the case, so VanillaBP does not save the aggregate after the method returned. This is on purpose, and it differs from a @WorkflowTask method, which is saved for.

The reason is that the method answers a question. A save as part of the report would write the case although nobody asked for that. Where the event came from a remote engine, that write lands beside the writes of your application, and one of the two gets a version conflict for no good reason.

So change nothing here. If you do want to change something, save it through your own persistence:

@UserTaskDetailsProvider
public UserTaskDetails retrievePayment(
        final Ride ride,
        final PrefilledUserTaskDetails details) {
    ride.setLastSeenInTheCockpit(OffsetDateTime.now());
    rides.save(ride);
    return details;
}

One thing to know before you rely on it either way: whether an unsaved change survives is up to your persistence, not up to VanillaBP. JPA writes what changed on a managed object when the transaction commits, asked for or not. VanillaBP stops saving for you, it cannot stop your persistence from writing. So a change you make here and do not save is still your change, with whatever your persistence does about it. Whichever way you write here, give the aggregate a version attribute. An event of a remote engine is reported in a transaction of its own, so a write of yours and a write of the report can meet. Without the attribute, the later of the two silently wins. Architecture says what happens with one.

Describing a business case

The same idea, once per workflow rather than once per task. @WorkflowDetailsProvider names no task: a @WorkflowService class serves one business case, so one method serves every process of it. Its one attribute is version, which reads the way the one of a user task method reads, so one generation of your model gets a method of its own here too.

@WorkflowDetailsProvider
public WorkflowDetails rideDetails(
        final Ride ride,
        final PrefilledWorkflowDetails details) {
    details.setDetails(Map.of("from", ride.getFrom()));
    details.setAccessibleToGroups(List.of("DISPATCH"));
    return details;
}

It may ask for the workflow aggregate and for PrefilledWorkflowDetails, and for nothing else. That is less than a user task method may ask for, and there is a reason for it. A user task method is asked about one task and sees the whole context of that task, so a task variable, the lifecycle event and the multi-instance element it runs for make sense there. This method is asked about the case. There is no task to take a variable from, no lifecycle event of the kind a task has, and no multi-instance context, because a case is never an element of a multi-instance. Even the id of a task is not a parameter over there. It comes with the prefilled details. Here the aggregate is the case, so these two are all there is to ask for. Any other parameter ends the start of your application, and the message names the method and says what may stand there.

The aggregate is there to be read, the same way it is for a user task, and the same applies to changing it: save it yourself or leave it alone.

Two setters on the prefilled object decide who may open the case: setAccessibleToUsers and setAccessibleToGroups. A case which names neither is open to everybody who may use the cockpit. Security says what that means for the list and for the status site.

Business data in a list column

Both details objects carry a details map, and that map is what a list column reads. The key is the column's name, the value is what the column shows.

final var customer = new RideCustomerDetails();
customer.setName(ride.getCustomerName());
details.setDetails(Map.of(
        "customer", customer,
        "amount", ride.getPrice()));

A value may be a simple one or an object. Objects are serialized to JSON on the way, so put a small class of your own in there rather than a business object: what goes into the cockpit is a copy which is searched and sorted, and dragging a whole aggregate along makes every report bigger and every change to the aggregate a change to the cockpit's data.

detailsFulltextSearch is the other half of finding things. It is one string per task or case, and the cockpit's search box looks into it. Whatever a user would type to find this case belongs in there.

Which columns a list actually has, and how a value is rendered, is decided in the user interface of your workflow module. User task forms and status sites is about that side.

Organizing user tasks

The details of a user task also say who is meant to work on it and who hears about it.

assignee is the one person the task belongs to. candidateUsers and candidateGroups are who may take it. excludedCandidateUsers is who may not, whatever the other two say, and it is what four eyes are made of: the person who did the previous step is barred from the next one.

These four are read once, when the cockpit stores the task for the first time. Your provider is still called for every later report, and setting the same values again does no harm, but a different value is not used. After that only the users of the cockpit change the task: taking it over makes somebody its assignee, and assigning it to somebody makes them a candidate. A later report never moves a task away from the person who has it. Only a task which names nobody, and is therefore shown to everybody, takes the names from the first later report which has some. If the work has to go to other people, end the task and start a new one, for instance with a boundary event that leads back into the same user task. The new task gets the new values.

admittedUsers is who may see the task without being asked to work on it. Anybody you put there reaches the task in the cockpit, whether or not they are a candidate and whatever their groups look like later on. The common use is reading back a case: keep in your aggregate who worked on which task, and report that list, so a clerk can still open what they entered after they moved to another team. Naming somebody in both lists lets them read the task, because an admission is the stronger word of the two.

@UserTaskDetailsProvider(taskDefinition = "approve")
public void approveDetails(
        final Ride ride,
        final PrefilledUserTaskDetails details) {

    details.setCandidateGroups(List.of("APPROVERS"));
    details.setAdmittedUsers(ride.getClerksWhoWorkedOnIt());

}

Unlike the four above, this list is read each time you report the task, and nothing is added behind your back. A task reported before you filled the list carries nobody until you report it again, which aggregateChanged below is for.

What an admitted reader gets to see is your decision, not the cockpit's. The cockpit lists the task and opens your form, and your form decides whether they only read or may still change something. Security says how the cockpit weighs these fields against each other.

dueDate and followUpDate are the two dates the lists sort and filter by. Where they come from is a business question, and the aggregate usually holds the answer.

initiator is who caused the task, and it is the one field you have to answer. The cockpit does not notify somebody about their own work, so setting it is also what keeps a user from getting an e-mail about the task they just created. Who caused it below is about that field alone.

notificationDelivery decides whether the cockpit notifies at all. USER_CONFIG leaves it to each user's own notification settings and is the default. FORCE notifies whatever those settings say, and the notification is marked as forced. SUPPRESS means the cockpit sends nothing, which is what you want when your own application already sends a message about this task.

How these fit together in a running application is easier to read as code than as a list. The blueprint blueprint-workflowmodule-springboot-standalone-businesscockpit is a workflow module wired to a cockpit end to end. It is built on the version 1 adapters, so take it as the shape of such an application rather than as code to copy; Migrating from version 1 says what differs.

Which of them to reach for in which situation is not written down anywhere yet, and the patterns people have arrived at over the years live in their heads.

Who caused it: the initiator

Every report names an initiator, on both details objects. It is the user who caused what is being reported: the person who pressed the button a case was started from, the person who assigned a task or took it for themselves. The cockpit shows it as a column a reader can sort and filter by. It also reads it to leave somebody out of a notification about their own work.

Your workflow module is the only one who can answer it. No workflow system records that user. Camunda 8 names nobody, the Process-Engine-API names nobody, and Camunda 7 knows the user who created a process instance, but only where the application set an identity context of its own. That says nothing about who changed a task. VanillaBP cannot fill the gap either: a report is built at the moment of the event, and the security context of the request which caused it is gone by then. It is gone on a retry as well, so there is no later moment which knows more.

So the user belongs in the workflow aggregate, written down while you still have it and read back in your details provider. A ThreadLocal does not reach this far, and a retry finds nothing in it either.

One key says who answers, and it has no default. An application which reports to the cockpit and leaves it out does not start.

vanillabp:
  cockpit:
    initiator-source: by-application

by-application means your provider sets it:

@WorkflowDetailsProvider
public WorkflowDetails rideDetails(
        final Ride ride,
        final PrefilledWorkflowDetails details) {
    details.setInitiator(ride.getOrderedBy());
    return details;
}

A report which still names nobody after your provider ran fails, and it takes the engine's work with it, because the report is built in the transaction of the event. This is on purpose. A missing initiator is not a report which arrives late. It is a case which can never say who started it. Switch the field on a year later, and the old cases stay empty for good.

system means this workflow module knows no action a user causes, a nightly import for instance. Every report of it then names system, and nothing fails.

For a single event no user caused, set the constant Initiator.SYSTEM instead of a user id. It is the second answer you are allowed to give. It is also how you drop a value Camunda 7 prefilled. Calling setInitiator(null) looks exactly like a provider which did nothing, and the cockpit cannot tell the two apart.

A workflow module and a single workflow may differ from the application:

Key What it is
vanillabp.cockpit.initiator-source What holds for the whole application
vanillabp.workflow-modules.<moduleId>.cockpit.initiator-source What holds for one workflow module
vanillabp.workflow-modules.<moduleId>.workflows.<process>.cockpit.initiator-source What holds for one workflow of it

The most specific value wins, so a module which answers for itself can still hand one timer-started process over to the system. There is no level below a workflow: telling one task of a workflow apart from another is something your provider knows better than a configuration file.

One case is exempt from all of this. Where the engine says nothing at all about a task or a case and the cockpit reports its end without details, no provider runs, and that one report carries system whatever the key says. An end has to be reported, and a configuration cannot answer for an event nobody was asked about.

How an application carries the user from its own request into the aggregate is a pattern of its own, and a blueprint about organizing user tasks is being written for it.

Reporting a change from your own code

Not everything the cockpit shows changes because the engine did something. A price is corrected in a form, a document arrives from somewhere else, and the column in the list is stale although no task and no workflow moved.

BusinessCockpitService is how you say so. Inject it for your aggregate class and call aggregateChanged after you changed the aggregate:

@Autowired
private BusinessCockpitService<Ride> businessCockpitService;

@Transactional
public void cashWasGiven(final String rideId, final float outstanding) {
    final var ride = rideRepository.findById(rideId).orElseThrow();
    ride.setOutstandingPayment(outstanding);
    businessCockpitService.aggregateChanged(ride);
}

The method needs a transaction, and @Transactional is what gives it one. The report is written into that transaction, next to the change it is about, so the cockpit hears about the change only once your change is committed. Called from a thread without a transaction, aggregateChanged says so and names the method to annotate.

aggregateChanged(ride) reports the case and every user task of it. Naming task ids reports only those:

businessCockpitService.aggregateChanged(ride, userTaskId);

Either way your provider methods are called again, so the cockpit ends up with what they say now. The report takes the same road as every other one: an outbox entry in your transaction, sent after it commits.

Reading back what was reported

Sometimes your own application needs the text the cockpit shows. An overdue reminder by e-mail should carry the same task title the user sees in the list, and that title may have come from a template rather than from your code.

final var userTask = businessCockpitService.getUserTask(ride, userTaskId);
userTask.ifPresent(task -> sendReminder(
        ride.getEmailAddress(), task.getTitle(), task.getDueDate()));

getUserTask answers straight away and reports nothing. An empty Optional means the engine said nothing about that task. It does not mean the task is over.

Read it as "no details right now" and keep what you already had. The reason is that two of the three engines cannot promise more. Camunda 8 answers from a search store which an exporter fills after the engine, so a task created a moment ago is missing from the answer in the same way a task which ended is. The Process-Engine-API answers out of what the asking node was served. Camunda 7 asks its own engine inside your transaction, so there empty really is empty. A REST endpoint which turns the empty answer into a 404 claims more than it knows. The task it denies may be on somebody's screen in the Business Cockpit at that very moment. The end of a task reaches the cockpit when the engine reports it, never because a read found nothing.

Reading needs no transaction of your own, and it uses the one you are in where there is one. So the answer is built from the aggregate as your transaction sees it, the change you made a line earlier included, even though nothing has written it yet. Where you are in no transaction, say a REST endpoint reading a task for a page, VanillaBP opens one for the read and you get your answer all the same.

A provider which runs on a read should only read. It is called here like anywhere else, so a change it makes to the aggregate stays in your transaction and is written when you commit. Where your case notices a second writer, that commit can fail on a version conflict, and a read is a surprising place to learn about one. Architecture shows what that failure looks like and what to do with it.

Who may see a workflow module

One bean answers whether a workflow module shows up for a user at all:

public interface WorkflowModuleDetailsProvider {
    String getWorkflowModuleId();
    List<String> getAccessibleToGroups();
}

The user interface asks the cockpit which workflow modules it may load parts from, and this is the rule applied to that list. A module a user may not see is not loaded, so nothing that module contributes to the cockpit's own screens appears for them.

It says nothing about the two lists. Whether a user sees a task or a case is decided per task and per case, from what you reported about them, and that is described on Security.

Clone this wiki locally