# 0101 Separating concerns between workflow persisters and actions

Date: 2025-01-02

## Status

Accepted

## Context

The `Workflow` library provides a state machine engine which is used in
LedgerSMB to manage [resource
state](./0017-state-machines-for-resource-state-management.md). Other systems
use state machine engines for execution of processes; this is not the primary
purpose of `Workflow` in LedgerSMB, although it may - to some extent - be a
valid consequence.

The components in `Workflow` have specific roles. Since LedgerSMB's storage
medium (the PostgreSQL database) isn't just passive storage, but includes
stored procedures to become "active" as well, the allocation of
responsibilities becomes blurred: should the persister be the sole component
interacting with the database or should actions do so as well? Where to draw
the lines?

### Summary of the `Workflow` library

States are grouped into workflow types. These workflow types serve as templates
or classes which can be instantiated into actual workflows. These workflow
instances are what the workflow engine operates on; they are serialized for
storage and deserialized to be loaded back into the running application. In
LedgerSMB, the storage layer is the PostgreSQL database.

#### Components making up `Workflow`

The library consists of the following components:

* States  
  Define associated actions, their associated conditions and their transition
  target states.
* Conditions  
  Define conditions (Perl code) for availability of actions.
* Actions  
  Define which Perl code is run when the action is triggered -- this may
  include running stored procedures in the PostgreSQL database. Successful
  execution of an action results in a state transition (optionally back to
  the same state the workflow was already in).
* Validators  
  Define checks to establish input validity in the workflow state before
  executing the code associated with an action. Validators block execution
  of an action if the input data is incorrect, in contrast to conditions,
  which prevent actions from appearing as 'available'.
* Persisters  
  Store and load workflow state; in case of LedgerSMB, the state is stored in
  a PostgreSQL database in a.o. the `workflow` and `workflow_context` tables.
* Observers  
  Define Perl code to be triggered on events in a workflow. Observers get
  notified of state transitions, in contrast to actions which are the cause
  or trigger of state transitions.

#### Execution flow of `Workflow`

1. Start database transaction
2. Load workflow state from database
3. Conditions are checked to select available actions from the list of all
   actions defined in the current state
4. An action is picked to be triggered
5. Validators are run to check input validity for the selected action
6. The selected action is run
7. The workflow state is serialized to the database
8. The database transaction is committed

At various points in this flow observers are sent events from processing the
action and optional state changes.

### The problem with an active storage layer

The `Workflow` state management library divides the concerns "performing actions"
and "storing resulting workflow state" between the `Workflow::Action` and the
`Workflow::Persister` respectively. In the context of a passive storage backend
(such as a filesystem), the consequences are straight forward: the persister
serializes the context and stores it on disk. The rest happens in actions.

In LedgerSMB, the storage layer isn't just a passive component: it's a
PostgreSQL database server with stored procedures as active components. In case
of a passive storage, access to it is restricted to the persister. In case of
the active storage in LedgerSMB, this demarcation isn't as straight forward.

Consider period closing, for example: there are two actions (`close` and
`reopen`) which move the state of the workflow between states `CLOSED` and
`OPEN`. The persister stores the workflow state, which is the end date of the
closed period. Each workflow in `CLOSED` state should have an "accounting
snapshot" stored. No workflow in `OPEN` state can have an accounting snapshot:
the consequence of the `close` action hence should be the creation of a
snapshot. If the `close` action itself is to ensure the creation of the
snapshot, it needs access to the storage layer.

There are two options to deal with the active side of the storage layer:

1. Worklfow actions operate on the active side of the storage layer  
   i.e. workflow actions trigger stored procedures
2. Workflow persisters communicate with actions to make the persister
   perform the necessary actions  
   i.e. actions operate solely on workflow state, unable to access the
   persistence layer

The downside of (1) is that in its design, Workflow didn't account for
actions which need to access the database (storage) layer, requiring
customization of the workflow to support the pattern.

The downside of (2) is that it results in tight coupling between actions
and persisters: any new actions which require invocation of stored
procedures will need changes in the persister as well.

## Decision

The persister needs to store the workflow state. This could be simply in the
`workflow_context` and `workflow` tables as well as in additional tables, as
long as the function of the persister is restricted to transformation of
existing values, which excludes invoking stored procedures.

Actions will be responsible for invocation of stored procedures and modification
of other tables' contents except for modification of workflow state tables. The
key differentiation is that actions are responsible for execution of business
logic.

## Consequences

1. The `Workflow` class needs to be extended to become a `LedgerSMB::Workflow`
   which provides actions and conditions access to the database, e.g. through
   a `dbh` or `handle` accessor
2. All workflows need to be declared of type `LedgerSMB::Workflow`, overriding
   the default `Workflow`
3. The existing implementation which follows pattern (2) -- ie.
   the TransactionApprove & TransactionDelete actions and the
   JournalEntry persister -- must be refactored to pattern (1) as per this
   decision

## Annotations

