workflow

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 14, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

README

workflow

A lightweight, state-driven approval-workflow library for Go.

The engine itself is stateless: each call takes the application snapshot, the template, and (where relevant) the audit log, and returns the next state plus a fresh ReviewLog entry. A separate Service wraps the engine with a Store so transitions are persisted atomically. A reference store backed by GORM is provided.

Install

go get github.com/weedbox/workflow

Requires Go 1.26+.

Quickstart

package main

import (
    "context"
    "log"

    "github.com/weedbox/workflow"
    "github.com/weedbox/workflow/gormstore"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    ctx := context.Background()

    db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    store := gormstore.New(db)
    _ = store.AutoMigrate()
    svc := workflow.NewService(store, nil) // nil engine = default

    tpl := workflow.WorkflowTemplate{
        ID:         "leave-request",
        ReturnMode: workflow.ReturnModeDirect,
        Stages: []workflow.WorkflowStage{
            {StageIndex: 1, ReviewType: workflow.ReviewTypeSingle, ApproverIDs: []string{"manager"}},
            {StageIndex: 2, ReviewType: workflow.ReviewTypeSingle, ApproverIDs: []string{"director"}},
        },
    }
    if err := svc.SaveTemplate(ctx, tpl); err != nil {
        log.Fatal(err)
    }

    app := workflow.Application{ID: "leave-001", WorkflowID: tpl.ID, OwnerID: "alice"}
    _ = svc.CreateDraft(ctx, app)
    _, _, _ = svc.Submit(ctx, app.ID)
    _, _, _ = svc.Approve(ctx, app.ID, "manager")
    _, _, _ = svc.Approve(ctx, app.ID, "director")
    // app.Status is now "Approved"
}

Switching to Postgres is a one-line change at gorm.Open; everything below is identical. See examples/quickstart for the runnable version.

Core concepts

Template

A WorkflowTemplate is a static configuration: an ID, a return mode, and an ordered list of stages.

type WorkflowTemplate struct {
    ID         string
    ReturnMode string          // "STRICT" or "DIRECT"
    Stages     []WorkflowStage // 1-based, contiguous StageIndex
}

type WorkflowStage struct {
    StageIndex  int
    ReviewType  string   // "SINGLE" or "ALL"
    ApproverIDs []string // can be empty when using a custom ApproverResolver
}
Statuses
Status Meaning
Draft Created but not submitted. Owner can edit; nobody else sees it.
In_Review Submitted; sitting at CurrentStageIndex.
Returned Sent back to owner from ReturnStageIndex for revision.
Approved Terminal. All stages passed.
Rejected_Closed Terminal. A reviewer force-closed the case.
Review types
  • SINGLE: any one approver in the stage's pool advances the workflow.
  • ALL: every approver in the pool must approve before the stage advances.

Multi-sign rounds are bounded by the most recent SUBMIT log: a re-submission discards prior approvals so reviewers see a fresh round.

Return modes

When a reviewer returns an application, Resubmit re-enters review according to the template's ReturnMode:

  • DIRECT: resumes at the original return stage (ReturnStageIndex).
  • STRICT: restarts from stage 1.

State transitions

All transitions are methods on Service (which delegates to WorkflowEngine inside a store transaction):

svc.Submit(ctx, applicationID)                       // Draft     -> In_Review @1
svc.Approve(ctx, applicationID, operatorID)          // advances stage; terminal -> Approved
svc.Return(ctx, applicationID, operatorID, comment)  // In_Review -> Returned (comment required)
svc.Resubmit(ctx, applicationID)                     // Returned  -> In_Review (per ReturnMode)
svc.RejectClose(ctx, applicationID, op, comment)     // -> Rejected_Closed (comment required)
svc.Withdraw(ctx, applicationID, comment)            // In_Review -> Draft (owner pulls the case back)
svc.RevokeApprove(ctx, applicationID, op, comment)   // ALL stage: take back your own pending approval

Every call returns the updated Application, the appended ReviewLog, and an error. Common engine errors: ErrInvalidStatus, ErrNoPermission, ErrCommentRequired, ErrAlreadyApproved, ErrInvalidTemplate, ErrTemplateMismatch, ErrTemplateEmpty, ErrStaleReturnStage, ErrRevokeNotAllowed, ErrNothingToRevoke.

Taking decisions back

Two transitions let participants undo their own action without an admin reflow:

  • Withdraw — the owner pulls a still-In_Review case back to Draft to edit and re-submit (or abandon). Approvals collected so far stay in the log but go inert: the next Submit opens a fresh round that excludes them. Only valid while In_Review; a Returned case is already in the owner's hands.
  • RevokeApprove — a reviewer cancels their own approval before it becomes decisive. This window only exists on an ALL (multi-sign) stage whose quorum is still outstanding: a REVOKE_APPROVE log nets out the earlier APPROVE, the stage stays put, and the reviewer may approve again later. SINGLE stages advance on the first signature (ErrRevokeNotAllowed), and once an ALL stage's last approval lands it advances — after which the reviewer is no longer the current-stage approver (ErrNoPermission).

Permission helpers

The engine exposes pure query methods for UI / API gating. They answer workflow-routing questions, not authentication:

engine.CanView(app, tpl, userID)        // owner + reviewers in reached stages
engine.CanEditForm(app, userID)         // owner-only, while Draft or Returned
engine.CanReview(app, tpl, userID)      // is operator the current-stage approver?
engine.CanResubmit(app, userID)         // owner-only, while Returned
engine.CanWithdraw(app, userID)         // owner-only, while In_Review
engine.CanRevokeApprove(app, tpl, userID, logs) // reviewer with a pending ALL-stage approval

CanView is a convenience. Coarse-grained roles like "admin sees everything" belong in your authentication layer — combine them as isAdmin(uid) || engine.CanView(...).

Dynamic approvers

ApproverResolver lets you compute the reviewer list at runtime instead of baking IDs into the template — useful for role-based, department-based, or amount-based routing.

engine := workflow.NewEngine()
engine.Resolver = workflow.ApproverResolverFunc(
    func(app workflow.Application, stage workflow.WorkflowStage) ([]string, error) {
        return orgChartLookup(app.OwnerID, stage.StageIndex), nil
    },
)
svc := workflow.NewService(store, engine)

With a custom resolver, WorkflowStage.ApproverIDs may be left empty; the resolver supplies the list per (app, stage). The result must be stable for a given (app, stage) within a single approval round so ALL multi-sign counting stays coherent.

The default resolver returns stage.ApproverIDs verbatim, preserving the static-template behaviour for callers who don't need dynamic routing.

See examples/dynamic_resolver for a runnable case.

Template updates and reflow

Service.SaveTemplate writes the new template and resets every in-flight application bound to it atomically:

  • In_Review / Returned applications: reset to In_Review, CurrentStageIndex=1, a fresh SUBMIT log is appended so prior approvals no longer auto-advance.
  • Approved / Rejected_Closed: terminal, untouched.

This is intentionally aggressive: any SaveTemplate triggers reflow, even when the definition is unchanged. Skip the call upstream if you want a no-op.

Storage

gormstore is the reference implementation. Use any GORM driver:

db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
store := gormstore.New(db)
_ = store.AutoMigrate()

Transactional reads acquire row locks (FOR UPDATE) on Postgres, MySQL, and SQL Server; SQLite falls back to its single-writer semantics. The log table carries an internal auto-increment seq column for stable ordering across drivers.

To plug in your own backend, implement the Store interface in store.go.

Authorization boundary

The library does not authenticate callers. Submit, Resubmit, Withdraw, and SaveTemplate trust the caller to have already verified the requester (the typical rule is requester == app.OwnerID for submit-side actions, and a workflow-admin role for SaveTemplate). Approve / Return / RejectClose / RevokeApprove take an operatorID and the engine checks it against the resolved approver list — that is workflow routing, not identity verification. Wire authentication in the layer above.

Examples

Path Demonstrates
examples/quickstart/ Minimal end-to-end usage.
examples/full_workflow/ Multi-sign stages, return/resubmit, RejectClose, template reflow.
examples/dynamic_resolver/ Per-application reviewer lookup via ApproverResolver.

Run any of them with:

go run ./examples/<name>/

Tests

go test ./...

License

See LICENSE.

Documentation

Index

Constants

View Source
const (
	StatusDraft          = "Draft"
	StatusInReview       = "In_Review"
	StatusReturned       = "Returned"
	StatusApproved       = "Approved"
	StatusRejectedClosed = "Rejected_Closed"
)

Application status values.

View Source
const (
	ReturnModeStrict = "STRICT"
	ReturnModeDirect = "DIRECT"
)

Return mode values for WorkflowTemplate.

View Source
const (
	ReviewTypeSingle = "SINGLE"
	ReviewTypeAll    = "ALL"
)

Review type values for WorkflowStage.

View Source
const (
	ActionSubmit      = "SUBMIT"
	ActionApprove     = "APPROVE"
	ActionReturn      = "RETURN"
	ActionRejectClose = "REJECT_CLOSE"
	// ActionWithdraw is recorded when the owner pulls a still-in-review
	// application back to Draft.
	ActionWithdraw = "WITHDRAW"
	// ActionRevokeApprove is recorded when a reviewer cancels their own,
	// not-yet-decisive approval within the current round.
	ActionRevokeApprove = "REVOKE_APPROVE"
)

Action values for ReviewLog.

Variables

View Source
var (
	ErrInvalidStatus    = errors.New("workflow: invalid application status for this operation")
	ErrNoPermission     = errors.New("workflow: operator has no permission to perform this action")
	ErrCommentRequired  = errors.New("workflow: comment is required for this action")
	ErrTemplateEmpty    = errors.New("workflow: template has no stages")
	ErrTemplateMismatch = errors.New("workflow: template does not match application")
	ErrAlreadyApproved  = errors.New("workflow: operator has already approved in current round")
	ErrInvalidTemplate  = errors.New("workflow: invalid template configuration")
	ErrStaleReturnStage = errors.New("workflow: stored return-stage index is out of range for the current template")
	ErrRevokeNotAllowed = errors.New("workflow: approval cannot be revoked for this stage type")
	ErrNothingToRevoke  = errors.New("workflow: operator has no live approval to revoke in the current round")
)

Engine errors.

View Source
var ErrNotFound = errors.New("workflow: record not found")

ErrNotFound is returned by Store implementations when a record cannot be located.

Functions

This section is empty.

Types

type Application

type Application struct {
	ID                string
	WorkflowID        string
	OwnerID           string
	Status            string
	CurrentStageIndex int
	ReturnStageIndex  int
}

Application represents a single workflow instance and its mutable state.

type ApproverResolver

type ApproverResolver interface {
	Resolve(app Application, stage WorkflowStage) ([]string, error)
}

ApproverResolver returns the user IDs allowed to approve a stage for a specific application. Implementations may consult external state (RBAC, org chart, per-application metadata) so reviewers do not have to be hard-coded in the template.

Stability contract: the result MUST be stable for a given (app, stage) within a single approval round. ALL multi-sign counting compares the number of distinct approvals in the round against len(Resolve(...)), so a list that grows or shrinks mid-round will confuse the engine.

An empty list disables the stage: SINGLE can never advance, ALL auto-advances on the first call. Both are almost always bugs — callers should arrange for Resolve to fail loudly instead of returning [].

type ApproverResolverFunc

type ApproverResolverFunc func(app Application, stage WorkflowStage) ([]string, error)

ApproverResolverFunc adapts a plain function to ApproverResolver.

func (ApproverResolverFunc) Resolve

func (f ApproverResolverFunc) Resolve(app Application, stage WorkflowStage) ([]string, error)

Resolve implements ApproverResolver.

type ReviewLog

type ReviewLog struct {
	ID            string
	ApplicationID string
	StageIndex    int
	OperatorID    string
	Action        string
	Comment       string
	CreatedAt     time.Time
}

ReviewLog records a single action performed against an Application.

type Service

type Service struct {
	Engine *WorkflowEngine
	Store  Store
}

Service is a convenience facade that combines a WorkflowEngine with a Store. Each public method loads the application, invokes the engine, then persists the new state and log inside a single transaction.

Authorization boundary: Service is *not* an authentication layer. Submit, Resubmit and SaveTemplate do not take an operator argument and trust the caller to have already verified that the requester is allowed to perform the action (typically: requester == app.OwnerID for Submit/Resubmit, and a workflow admin role for SaveTemplate). Approve / Return / RejectClose do take an operatorID and the engine checks it against the stage's ApproverIDs, but that is a workflow-routing check, not a caller-identity check — wire authentication in the layer above.

func NewService

func NewService(store Store, engine *WorkflowEngine) *Service

NewService wires an Engine (or a default one) with the provided Store.

func (*Service) Approve

func (s *Service) Approve(ctx context.Context, applicationID, operatorID string) (Application, ReviewLog, error)

Approve records a stage approval, advancing the workflow when conditions are met.

func (*Service) CreateDraft

func (s *Service) CreateDraft(ctx context.Context, app Application) error

CreateDraft persists a brand new draft application. It is a thin helper — callers can also write the application themselves via Store.SaveApplication.

func (*Service) GetApplication

func (s *Service) GetApplication(ctx context.Context, applicationID string) (Application, error)

GetApplication loads a snapshot of the application state.

func (*Service) ListLogs

func (s *Service) ListLogs(ctx context.Context, applicationID string) ([]ReviewLog, error)

ListLogs returns the chronological review log for an application.

func (*Service) RejectClose

func (s *Service) RejectClose(ctx context.Context, applicationID, operatorID, comment string) (Application, ReviewLog, error)

RejectClose terminally closes the application.

func (*Service) Resubmit

func (s *Service) Resubmit(ctx context.Context, applicationID string) (Application, ReviewLog, error)

Resubmit puts a Returned application back into the review queue.

func (*Service) Return

func (s *Service) Return(ctx context.Context, applicationID, operatorID, comment string) (Application, ReviewLog, error)

Return sends the application back to the owner for revision.

func (*Service) RevokeApprove added in v0.2.0

func (s *Service) RevokeApprove(ctx context.Context, applicationID, operatorID, comment string) (Application, ReviewLog, error)

RevokeApprove cancels operatorID's own pending approval at the current stage. Only valid for an ALL stage whose quorum is still outstanding.

func (*Service) SaveTemplate

func (s *Service) SaveTemplate(ctx context.Context, tpl WorkflowTemplate) error

SaveTemplate persists a workflow template definition and, in the same transaction, reflows every in-flight application bound to that template.

Reflow semantics: every application whose Status is In_Review or Returned is reset to the post-first-Submit state (Status=In_Review, CurrentStageIndex=1, ReturnStageIndex=0) and gets a new SUBMIT log appended. Approved / Rejected_Closed applications are terminal and are left untouched.

This is intentionally aggressive — calling SaveTemplate again with the same definition still reflows. Callers who want a no-op update should compare templates upstream and skip the call.

func (*Service) Submit

func (s *Service) Submit(ctx context.Context, applicationID string) (Application, ReviewLog, error)

Submit moves a Draft application to In_Review.

func (*Service) Withdraw added in v0.2.0

func (s *Service) Withdraw(ctx context.Context, applicationID, comment string) (Application, ReviewLog, error)

Withdraw pulls a still-in-review application back to Draft on the owner's behalf. Like Submit/Resubmit it trusts the caller to have verified that the requester is the owner; gate with Engine.CanWithdraw upstream if needed.

type Store

type Store interface {
	// Templates
	SaveTemplate(ctx context.Context, t WorkflowTemplate) error
	GetTemplate(ctx context.Context, id string) (WorkflowTemplate, error)

	// Applications
	SaveApplication(ctx context.Context, a Application) error
	GetApplication(ctx context.Context, id string) (Application, error)
	// ListInFlightApplications returns every application bound to workflowID
	// whose Status is In_Review or Returned. Used by Service.SaveTemplate to
	// reflow in-flight applications whenever the template is updated.
	ListInFlightApplications(ctx context.Context, workflowID string) ([]Application, error)

	// Review logs
	AppendLog(ctx context.Context, l ReviewLog) error
	ListLogs(ctx context.Context, applicationID string) ([]ReviewLog, error)

	// Transact runs fn inside a single transaction. The Store handed to fn is
	// scoped to that transaction; commits/rollbacks are managed by the impl.
	Transact(ctx context.Context, fn func(s Store) error) error
}

Store abstracts the persistence layer for templates, applications and logs.

Implementations must keep ReviewLog ordering stable (insertion order) since the engine treats the latest SUBMIT entry as the "current round" boundary.

type WorkflowEngine

type WorkflowEngine struct {
	// IDGenerator produces ReviewLog IDs. Swap for deterministic IDs in tests.
	IDGenerator func() string
	// Clock returns "now" when writing CreatedAt on ReviewLog entries.
	Clock func() time.Time
	// Resolver decides which user IDs may approve each stage. Defaults to a
	// resolver that returns stage.ApproverIDs as-is. Swap in a custom
	// implementation to do role/group/department-based routing without
	// changing the template schema.
	Resolver ApproverResolver
}

WorkflowEngine is the state-driven core of the library. It is stateless across calls; callers persist Application and ReviewLog values returned by the engine.

func NewEngine

func NewEngine() *WorkflowEngine

NewEngine returns a WorkflowEngine wired with default ID/clock implementations and the static ApproverIDs resolver.

func (*WorkflowEngine) Approve

func (e *WorkflowEngine) Approve(app Application, template WorkflowTemplate, operatorID string, logs []ReviewLog) (Application, ReviewLog, error)

Approve records an approval. With ReviewTypeAll it counts approvals collected in the current round; the stage advances only when the full set has approved.

func (*WorkflowEngine) CanEditForm

func (e *WorkflowEngine) CanEditForm(app Application, userID string) bool

CanEditForm reports whether userID may modify the application form fields. Only the owner, and only while in Draft or Returned, may edit.

func (*WorkflowEngine) CanResubmit

func (e *WorkflowEngine) CanResubmit(app Application, userID string) bool

CanResubmit reports whether userID may re-submit a returned application.

func (*WorkflowEngine) CanReview

func (e *WorkflowEngine) CanReview(app Application, template WorkflowTemplate, userID string) bool

CanReview reports whether userID is one of the approvers for the current stage. It performs the static permission check; the "already approved in current round" check is enforced inside Approve.

func (*WorkflowEngine) CanRevokeApprove added in v0.2.0

func (e *WorkflowEngine) CanRevokeApprove(app Application, template WorkflowTemplate, operatorID string, logs []ReviewLog) bool

CanRevokeApprove reports whether operatorID may cancel their own approval at the current stage. Revocation is only meaningful for an ALL (multi-sign) stage that has NOT yet collected enough approvals to advance: once the stage advances, the operator is no longer the current-stage reviewer and CanReview already returns false. SINGLE stages advance on the first approval, so there is never a pending approval to take back.

func (*WorkflowEngine) CanView

func (e *WorkflowEngine) CanView(app Application, template WorkflowTemplate, userID string) bool

CanView reports whether userID is allowed to read the application. Owner can always view. Approvers in stages reached so far can view as well.

func (*WorkflowEngine) CanWithdraw added in v0.2.0

func (e *WorkflowEngine) CanWithdraw(app Application, userID string) bool

CanWithdraw reports whether userID may pull the application back to Draft. Only the owner, and only while the case is still In_Review, may withdraw — once a case is Returned it is already in the owner's hands, and terminal states cannot be reopened.

func (*WorkflowEngine) RejectClose

func (e *WorkflowEngine) RejectClose(app Application, template WorkflowTemplate, operatorID string, comment string) (Application, ReviewLog, error)

RejectClose terminally closes the application. Requires a comment.

func (*WorkflowEngine) ResetToFirstSubmit

func (e *WorkflowEngine) ResetToFirstSubmit(app Application, template WorkflowTemplate) (Application, ReviewLog, error)

ResetToFirstSubmit rolls a non-terminal application back to the state it occupied immediately after the very first Submit: Status = In_Review, CurrentStageIndex = 1, ReturnStageIndex = 0. A fresh SUBMIT log is produced so the engine's round-boundary logic invalidates every stale APPROVE.

Service.SaveTemplate calls this on every in-flight application whenever the template definition changes, so reviewers re-approve the new flow.

func (*WorkflowEngine) Resubmit

func (e *WorkflowEngine) Resubmit(app Application, template WorkflowTemplate) (Application, ReviewLog, error)

Resubmit moves a Returned application back into review. STRICT mode restarts from stage 1; DIRECT mode resumes at the original return stage.

func (*WorkflowEngine) Return

func (e *WorkflowEngine) Return(app Application, template WorkflowTemplate, operatorID string, comment string) (Application, ReviewLog, error)

Return puts the application back to the owner for modification. Records the originating stage in ReturnStageIndex for DIRECT-mode resubmission.

func (*WorkflowEngine) RevokeApprove added in v0.2.0

func (e *WorkflowEngine) RevokeApprove(app Application, template WorkflowTemplate, operatorID string, comment string, logs []ReviewLog) (Application, ReviewLog, error)

RevokeApprove cancels the operator's own approval at the current stage, appending a REVOKE_APPROVE log that nets out their earlier APPROVE for the round. The application state is unchanged — the stage has not advanced, so only the running approval count drops by one and the operator is free to approve again later.

It is intentionally limited: a reviewer can only take back an approval that has NOT yet pushed the stage forward. That window exists only for ALL (multi-sign) stages with an outstanding quorum — see CanRevokeApprove.

  • SINGLE stages advance immediately, so there is nothing pending to revoke (returns ErrRevokeNotAllowed).
  • Once an ALL stage collects its last approval it advances, after which the operator fails CanReview (returns ErrNoPermission).

func (*WorkflowEngine) Submit

Submit transitions a Draft application into In_Review at stage 1.

func (*WorkflowEngine) Withdraw added in v0.2.0

func (e *WorkflowEngine) Withdraw(app Application, template WorkflowTemplate, comment string) (Application, ReviewLog, error)

Withdraw lets the owner pull a still-in-review application back to Draft so it can be edited and submitted again (or abandoned). The originating stage is recorded on the WITHDRAW log for audit; the application's stage pointers are cleared. Any approvals collected so far are left in the log but become inert: the next Submit appends a fresh SUBMIT entry, which starts a new round and excludes them from the count.

Caller identity is trusted, mirroring Submit/Resubmit: the engine only enforces that the case is In_Review and stamps the log with app.OwnerID. Use CanWithdraw to gate the action on owner identity in the layer above.

type WorkflowStage

type WorkflowStage struct {
	StageIndex  int
	ReviewType  string
	ApproverIDs []string
}

WorkflowStage defines a single approval stage.

type WorkflowTemplate

type WorkflowTemplate struct {
	ID         string
	ReturnMode string
	Stages     []WorkflowStage
}

WorkflowTemplate defines the static configuration of a workflow.

Directories

Path Synopsis
examples
dynamic_resolver command
Dynamic ApproverResolver example: reviewers are looked up at runtime from per-application context instead of being baked into the template.
Dynamic ApproverResolver example: reviewers are looked up at runtime from per-application context instead of being baked into the template.
full_workflow command
Comprehensive example covering every public flow:
Comprehensive example covering every public flow:
quickstart command
Quickstart example: shortest end-to-end usage.
Quickstart example: shortest end-to-end usage.
Package gormstore provides a GORM-backed workflow.Store implementation.
Package gormstore provides a GORM-backed workflow.Store implementation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL