Skip to content
Ocean-Atlasv0.1.0Canonical Knowledge

Approval Workflow

A persistent record — an invoice, a request, a document — needs a bounded set of human-in-the-loop decisions, typically approve or reject, each of which must record who decided and when, and each of which is a terminal or near-terminal transition.

How do you model a decision-driven lifecycle where each transition is triggered by a specific actor’s decision, and that actor and decision must be captured as part of the transition itself, not just the resulting status?

  • Consistency — the recorded decision (approver, timestamp, remark or reason) should be updated in the same transition handler as the state change it causes.
  • Coupling — the FSM needs to resolve the deciding actor without owning that actor’s entity itself.
  • Reuse — approve/reject is a recurring shape across many domains (invoices, requests, orders) and should follow the same modeling structure each time.
  • Complexity — some transitions are effectively terminal (Approved, Rejected) and need no further events.

Apply Entity-Backed FSM with events shaped as decisions: event approve in(req:ApproveReq) out(...) and event reject in(req:RejectReq) out(...). Inside each transition, resolve the deciding actor, stamp the decision fields on this, and move to the terminal state with next.

stateDiagram-v2
[*] --> PendingApproval
PendingApproval --> Approved: approve (records approver, approvedAt)
PendingApproval --> Rejected: reject (records rejecter, rejectedAt)
Approved --> [*]
Rejected --> [*]
@fsm
InvoiceApprovalFSM controls Invoice.status
key: id
event approve in(appReq:ApproveInvoiceReq) out(appInvoice:*Invoice)
event reject in(rejReq:*RejectInvoiceReq) out(rejInvoice:Invoice)
state PendingApproval
on event approve:
this.remark = appReq.remark
this.approvedAt = nowUTC()
approver = User.findById(appReq.approvedBy)
this.approvedBy = approver
next Approved
on event reject:
this.remark = rejReq.reason
this.rejectedAt = nowUTC()
rejector = User.findById(rejReq.rejectedBy)
this.rejectedBy = rejector
next Rejected
state Approved
state Rejected

This is a minimal illustrative fragment, not a complete duplicated example — see Working Examples for the full, runnable model.

  • Decision data and the state change are defined together in one transition handler, making the required actor and audit fields explicit.
  • The shape generalizes across domains: the same approve/reject structure fits invoices, requests, or any decision-gated record.
  • This pattern models exactly one decision per transition. Multi-step or multi-approver workflows (for example, two required approvals) need additional states and events, not a bigger approve event.
  • Resolving the deciding actor (User.findById) introduces a read dependency on another entity during the transition.
  • Transaction-level atomicity depends on the active FSM, persistence engine, and generator contract; this pattern does not establish that guarantee by itself.
  • If no human decision or actor needs to be recorded — the transition is purely system- or event-driven — use plain Entity-Backed FSM instead.
  • If the record being decided on has no persisted identity, this pattern does not apply; see In-Memory FSM.
  • Invoice Approval Workflow — an entity-based approval workflow connecting persistent invoices, users, customers, and recorded decisions.