workflow

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package workflow is a generic workflow/BPM engine: it owns case state, transitions, assignment, and history for any process a consumer chooses to model as a Definition.

The defining constraint is that the engine stores ZERO domain data. A Case carries only a reference — Domain and ExternalID, e.g. ("compras", "solicitud-123") — never the purchase order, the teacher record, or any other business payload. There is no form builder, no key/value "tracked data" table, and no JSON blob of user fields attached to a case. A consumer that needs to show "what is this case about" looks the referenced object up in its own domain store using (Domain, ExternalID); this package has no opinion on, and no access to, that data.

Assignment is by organizational position and unit (see Eligibility), never by a person ID baked into the Definition. Resolving "who currently holds that position" — including deputy/subrogación handling — is deliberately left to a separate org-context service the caller owns: EligibilityFor returns the Eligibility for a case's current node, and the caller resolves it to actual people using whatever authority source it already has. This package never needs to know that resolution happened, or how.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrDefinitionNotRegistered = errors.New("workflow: definition not registered")
	ErrGuardNotRegistered      = errors.New("workflow: guard not registered")
	ErrCaseNotFound            = errors.New("workflow: case not found")
	ErrCaseExists              = errors.New("workflow: case already exists for that external reference")
	ErrCaseClosed              = errors.New("workflow: case is closed")
	ErrInvalidTransition       = errors.New("workflow: no such transition from current state")
	ErrGuardRejected           = errors.New("workflow: guard rejected the transition")
	ErrNotAssigned             = errors.New("workflow: case is not assigned")
	ErrAlreadyAssigned         = errors.New("workflow: case is already assigned")
	ErrInvalidDefinition       = errors.New("workflow: invalid definition")
	ErrCommentRequired         = errors.New("workflow: comment is required for this transition")
	ErrCommentNotAllowed       = errors.New("workflow: comment is not allowed for this transition")
	ErrNoRoute                 = errors.New("workflow: no route matched at decision node")
	ErrTooManyDecisionHops     = errors.New("workflow: exceeded maximum decision node hops")
	ErrReturnNotVisited        = errors.New("workflow: case never occupied the return transition's target node")
)

Sentinel errors callers can branch on via errors.Is.

Functions

This section is empty.

Types

type Case

type Case struct {
	ID         uuid.UUID
	Definition string // definition name
	Version    int
	Domain     string // e.g. "compras"
	ExternalID string // e.g. "solicitud-123"
	Unit       string // organizational unit the case belongs to
	State      string // current node ID
	Status     Status
	AssignedTo string // person id; empty when unclaimed
	OpenedAt   time.Time
	ClosedAt   *time.Time
	DeadlineAt *time.Time
}

Case is a single running (or completed) instance of a Definition. It carries only a reference to the domain object it concerns — Domain and ExternalID — never the object's own data.

type CommentPolicy added in v0.6.0

type CommentPolicy string

CommentPolicy controls whether a comment is required, optional, or disallowed when a Transition is taken. It is process history, not domain data: the engine stores the comment text on the Event it appends, but never interprets it.

const (
	// CommentNone is the default: MoveInput.Comment must be empty (after
	// trimming) or Move fails with ErrCommentNotAllowed. Both the zero
	// value "" and the explicit literal "none" mean CommentNone, so a
	// Definition can spell out "no comment" without relying on the zero
	// value — see CommentPolicy.Canonical for how the alias is collapsed
	// back to this constant before any comparison.
	CommentNone CommentPolicy = ""
	// CommentOptional allows, but does not require, a comment.
	CommentOptional CommentPolicy = "optional"
	// CommentRequired fails Move with ErrCommentRequired when
	// MoveInput.Comment is empty after trimming.
	CommentRequired CommentPolicy = "required"
)

func (CommentPolicy) Canonical added in v0.6.0

func (p CommentPolicy) Canonical() CommentPolicy

Canonical collapses the "none" alias into the CommentNone zero value, so every comparison site sees the same value regardless of which spelling a Definition used. Definition.Validate cannot normalize transitions in place for the caller — it has a value receiver, so any mutation would be discarded when it returns — so canonicalization happens here instead, at every comparison point (currently just checkComment). Comparing a raw, non-canonicalized CommentPolicy against the CommentNone constant with == silently disagrees for an alias-spelled value; call Canonical() first.

func (CommentPolicy) Valid added in v0.6.0

func (p CommentPolicy) Valid() bool

Valid reports whether p is one of the known CommentPolicy values.

type Config

type Config struct {
	// Now returns the current time. Nil uses time.Now — inject a fixed clock
	// in tests.
	Now func() time.Time
}

Config configures optional Engine behaviour.

type Definition

type Definition struct {
	Name        string
	Version     int
	Nodes       []Node
	Transitions []Transition
}

Definition describes one version of a workflow: its nodes, and the transitions permitted between them.

func (Definition) GuardNames

func (d Definition) GuardNames() []string

GuardNames returns the sorted, deduplicated set of guard names referenced by this definition's transitions.

func (Definition) Key

func (d Definition) Key() string

Key returns the unique key this definition is registered under: "Name@Version".

func (Definition) Node

func (d Definition) Node(id string) (Node, bool)

Node returns the node with the given ID, if any.

func (Definition) StartNode

func (d Definition) StartNode() (Node, bool)

StartNode returns the definition's single start node, if any.

func (Definition) TransitionsFrom

func (d Definition) TransitionsFrom(nodeID string) []Transition

TransitionsFrom returns every transition originating at nodeID.

func (Definition) Validate

func (d Definition) Validate() error

Validate checks d for structural problems and returns a single error listing every violation found, wrapping ErrInvalidDefinition. It returns nil when d is well-formed.

type Eligibility

type Eligibility struct {
	Position string
	Unit     string
}

Eligibility declares who may act on a node, by organizational position. An empty Unit means "the unit the case belongs to" — see ResolveUnit.

Eligibility never names a person. Turning a position+unit pair into the people who currently hold it (including deputy/subrogación resolution) is the caller's org-context service's job, not this package's.

func ByPosition

func ByPosition(position string) Eligibility

ByPosition builds an Eligibility scoped to the case's own unit.

func ByPositionInUnit

func ByPositionInUnit(position, unit string) Eligibility

ByPositionInUnit builds an Eligibility pinned to an explicit unit, overriding the case's own unit.

func (Eligibility) IsZero

func (e Eligibility) IsZero() bool

IsZero reports whether e declares no position at all.

func (Eligibility) ResolveUnit

func (e Eligibility) ResolveUnit(caseUnit string) string

ResolveUnit returns e.Unit when explicitly set, or caseUnit otherwise.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine runs a set of registered Definitions against a Repository: it opens cases, evaluates and applies transitions, tracks assignment, and records history.

func New

func New(repo Repository, cfg Config, logger *slog.Logger, opts ...Option) *Engine

New creates an Engine backed by repo. logger is required by the surrounding convention (libraries take a stdlib *slog.Logger); a nil logger falls back to slog.Default().

func (*Engine) Available

func (e *Engine) Available(ctx context.Context, db pgxtx.DBTX, caseID uuid.UUID) ([]Transition, error)

Available returns the transitions leaving the case's current state whose guard (if any) currently evaluates true. A return transition (Transition. Return) is filtered out unless the case previously occupied its To node (see occupiedStates) — checked before the guard, exactly like Engine.Move orders the same two checks. A guard returning an error aborts the whole call. The case's event history is read at most once, and only when at least one candidate transition is a return.

func (*Engine) Claim

func (e *Engine) Claim(ctx context.Context, db pgxtx.DBTX, caseID uuid.UUID, actorID string) (*Case, error)

Claim assigns the case to actorID. Re-claiming by the same actor is a no-op success; claiming a case already assigned to someone else fails.

func (*Engine) Definitions

func (e *Engine) Definitions() []Definition

Definitions returns every registered definition, sorted by Name then Version, for introspection.

func (*Engine) EligibilityFor

func (e *Engine) EligibilityFor(ctx context.Context, db pgxtx.DBTX, caseID uuid.UUID) (Eligibility, error)

EligibilityFor returns the current node's eligibility for caseID, with ResolveUnit already applied against the case's own unit. This is what a caller hands to its org-context service to resolve actual people.

func (*Engine) History

func (e *Engine) History(ctx context.Context, db pgxtx.DBTX, caseID uuid.UUID) ([]Event, error)

History returns every event recorded for caseID, ordered by Seq ascending — see Event.Seq for why Seq, rather than OccurredAt, is the ordering key.

func (*Engine) Inbox

func (e *Engine) Inbox(ctx context.Context, db pgxtx.DBTX, f InboxFilter) ([]Case, error)

Inbox returns open cases matching f.

func (*Engine) Move

func (e *Engine) Move(ctx context.Context, db pgxtx.DBTX, in MoveInput) (*Case, error)

Move applies the transition matching (current state, Action) to the case, then — if that lands on a decision node — keeps routing automatically, within this same call and the same db transaction, until the case comes to rest on a task node or a terminal node. A case is never persisted resting on a decision node. Moving into a terminal node closes the case. Assignment is always cleared on a move, since a new node means a new claim.

The human-driven transition is checked in a fixed order before anything is mutated: first its CommentPolicy (see checkComment), then — only if it is a return transition (Transition.Return) — whether the case previously occupied its To node (see occupiedStates), then its Guard, if any. A return whose target the case never occupied fails with ErrReturnNotVisited before the guard ever runs, exactly like a rejected comment fails before the guard runs; either failure leaves the case and its history completely untouched. A return transition that passes all three checks is applied exactly like any other move: Deadline and eligibility are recomputed for the node the case comes to rest on, and assignment is cleared, same as always — Move has no separate "undo" semantics for a return, it is just an ordinary transition whose destination happens to be an earlier node in the case's own history.

Each hop along the way — the initial human-driven move and every automatic decision-node hop after it — appends its own EventMoved, with Action set to that hop's transition (or route) label and ActorID set to the original MoveInput.ActorID throughout. Only the first hop ever carries in.Comment; every automatic hop records an empty Comment, since the comment belongs to the human action that started the move, not to the domain-data routing that followed it. Deadline and eligibility only ever apply to the node the case actually comes to rest on.

If routing cannot find a matching route — a route's guard errors, or, defensively, a Definition that reached the engine without going through Validate somehow lacks an unguarded default — Move fails (with the guard's own error, or ErrNoRoute) and leaves the case and its history completely untouched: the whole hop chain is planned in memory, in planMove, before anything is written.

When the hop chain ends on a terminal node, the automatic EventClosed Move appends takes its Action from the LAST hop in the chain: the final automatic decision-node route's label when the human-driven transition landed on a decision node and routing continued from there, or the human-driven transition's own Action (in.Action) when it landed on the terminal node directly with no further hops. EventClosed.Action never repeats in.Action once a route hop followed it — it describes how the case actually reached the terminal node, not what the human asked for.

func (*Engine) Open

func (e *Engine) Open(ctx context.Context, db pgxtx.DBTX, in OpenInput) (*Case, error)

Open creates a case at the definition's start node and appends an EventOpened event as Seq 1. It is callable inside the caller's own transaction via db (see pgxtx.DBTX).

func (*Engine) Register

func (e *Engine) Register(d Definition) error

Register validates d and stores it under d.Key(). It also registers d under its bare Name, pointing at the highest version registered so far, so callers can open a case without pinning a version.

func (*Engine) RegisterGuard

func (e *Engine) RegisterGuard(name string, fn GuardFunc) error

RegisterGuard registers fn under name. Overwriting an existing name is allowed but logged at Warn.

func (*Engine) Release

func (e *Engine) Release(ctx context.Context, db pgxtx.DBTX, caseID uuid.UUID, actorID string) (*Case, error)

Release clears the case's assignment.

type Event

type Event struct {
	ID     uuid.UUID
	CaseID uuid.UUID
	// Seq is the case-scoped, gap-free, strictly increasing sequence number
	// that orders a case's events — the sole ordering key. Every event
	// appended by one Engine.Move call (the human-driven hop, each
	// automatic decision-node hop after it, and any automatic EventClosed)
	// shares the exact same OccurredAt timestamp (see Engine.appendEvent),
	// so a caller must never sort by OccurredAt to recover hop order within
	// one Move; Seq, assigned in append order (see Engine.nextSeq), is what
	// makes that order recoverable. workflow/postgres.ListEvents orders its
	// query by `seq ASC` for exactly this reason (see repository.go).
	Seq       int64
	Kind      EventKind
	FromState string
	ToState   string
	Action    string
	ActorID   string
	// Comment is the (already trimmed) comment or observation recorded
	// alongside this event, when the taken transition's CommentPolicy
	// allowed one. It is always empty on the automatic EventClosed event
	// Move appends when entering a terminal node — the comment belongs to
	// the EventMoved record for that same transition, not to its close
	// side effect, so it is never duplicated there.
	Comment    string
	OccurredAt time.Time
}

Event is a single append-only history record for a Case.

type EventKind

type EventKind string

EventKind identifies the kind of change an Event records.

const (
	EventOpened     EventKind = "opened"
	EventMoved      EventKind = "moved"
	EventAssigned   EventKind = "assigned"
	EventUnassigned EventKind = "unassigned"
	EventClosed     EventKind = "closed"
)

func (EventKind) Valid

func (k EventKind) Valid() bool

Valid reports whether k is one of the known EventKind values.

type GuardFunc

type GuardFunc func(ctx context.Context, db pgxtx.DBTX, c Case) (bool, error)

GuardFunc evaluates whether a transition may be taken for the given case. An error aborts the caller's operation rather than being treated as false.

db is the exact value the caller passed to Engine.Available or Engine.Move — never a different connection, and never nil unless the caller itself passed nil. A guard that reads domain data (e.g. "does this purchase have an item with subsidy SEP?") must see writes made earlier in the caller's own transaction — for example a row the caller inserted just before calling Move — so the engine hands the guard the same db rather than opening a separate connection or using its own pool.

Because db is shared with the caller's own transaction, a guard MUST return (never swallow) any database error it encounters, and MUST fully close any pgx.Rows it opens — via Rows.Close, not merely draining Next to false — before returning. A failed statement that is not reported leaves the transaction poisoned (PostgreSQL SQLSTATE 25P02, "current transaction is aborted"), and rows left open hold the underlying connection busy; either failure then breaks the engine's own writes later in the same call to Move.

type InboxFilter

type InboxFilter struct {
	Domain     string
	States     []string
	AssignedTo string // empty = any
	Unassigned bool   // true = only unclaimed
	Overdue    bool   // true = only past DeadlineAt
	Limit      int
	Offset     int
}

InboxFilter selects open cases. States is the set of node IDs a caller considers itself eligible for — that mapping from Position/Unit to a set of eligible node IDs is resolved by the caller (see Engine.EligibilityFor and the package doc comment), not by this filter.

type MoveInput

type MoveInput struct {
	CaseID  uuid.UUID
	Action  string
	ActorID string
	// Comment is the step comment (on approval) or observation (on
	// rejection) attached to this move. It is trimmed with strings.
	// TrimSpace, then validated against the taken transition's
	// CommentPolicy before any state change: empty after trimming on a
	// CommentRequired transition fails with ErrCommentRequired, and
	// non-empty on a CommentNone transition fails with
	// ErrCommentNotAllowed. It is stored as-is (trimmed) on the resulting
	// EventMoved record.
	Comment string
}

MoveInput describes a transition to apply to a case.

type Node

type Node struct {
	ID       string
	Start    bool
	Terminal bool
	Eligible Eligibility
	// Deadline is the duration after entering this node before it is
	// considered overdue. Zero means no deadline.
	Deadline time.Duration
	// Kind distinguishes an ordinary task node from a decision node. The
	// zero value is NodeTask. See the NodeKind doc comment.
	Kind NodeKind
}

Node is one state a Case can occupy within a Definition.

type NodeKind added in v0.6.0

type NodeKind string

NodeKind distinguishes a Node that rests on human action (NodeTask, the default) from one that routes automatically on domain data evaluated by its outgoing transitions' guards (NodeDecision) — a BPMN-style exclusive gateway. A Case is never persisted resting on a NodeDecision: Engine.Move keeps routing through it, within the same call, until it reaches a NodeTask or terminal node.

const (
	// NodeTask is the default: a node a Case can rest on between human
	// actions, exactly like every Node before decision nodes existed. Both
	// the zero value "" and the explicit literal "task" mean NodeTask, so a
	// Definition can spell out "this is an ordinary node" without relying on
	// the zero value.
	NodeTask NodeKind = ""
	// NodeDecision marks a decision node: see the NodeKind doc comment.
	NodeDecision NodeKind = "decision"
)

func (NodeKind) Valid added in v0.6.0

func (k NodeKind) Valid() bool

Valid reports whether k is one of the known NodeKind values.

type OpenInput

type OpenInput struct {
	Definition string
	Version    int // 0 = latest registered version
	Domain     string
	ExternalID string
	Unit       string
	ActorID    string
}

OpenInput describes a new case to open.

type Option

type Option func(*Engine)

Option configures optional Engine behaviour at construction time.

func WithDefinitions

func WithDefinitions(defs ...Definition) Option

WithDefinitions registers defs at construction time. It is a convenience over calling Register after New; a definition that fails validation is logged and skipped rather than aborting construction, since Option has no error return.

type Repository

type Repository interface {
	Create(ctx context.Context, db pgxtx.DBTX, c *Case) error
	GetByID(ctx context.Context, db pgxtx.DBTX, id uuid.UUID) (*Case, error)
	GetByExternalID(ctx context.Context, db pgxtx.DBTX, domain, externalID string) (*Case, error)
	Update(ctx context.Context, db pgxtx.DBTX, c *Case) error
	AppendEvent(ctx context.Context, db pgxtx.DBTX, e *Event) error
	ListEvents(ctx context.Context, db pgxtx.DBTX, caseID uuid.UUID) ([]Event, error)
	ListByEligibility(ctx context.Context, db pgxtx.DBTX, f InboxFilter) ([]Case, error)
}

Repository defines persistence operations for cases and their event history. See workflow/postgres for the PostgreSQL-backed implementation.

type Status

type Status string

Status represents the lifecycle state of a Case.

const (
	StatusOpen   Status = "open"
	StatusClosed Status = "closed"
)

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is one of the known Status values.

type Transition

type Transition struct {
	From   string
	To     string
	Action string
	// Guard names a GuardFunc registered on the Engine. Empty means the
	// transition (or, from a decision node, the route) is always permitted —
	// for a decision node's routes, this is what marks the default.
	Guard string
	// Comment declares this transition's CommentPolicy: whether MoveInput.
	// Comment is required, optional, or disallowed when this transition is
	// taken. The zero value is CommentNone. Definition.Validate rejects an
	// unrecognized value, and rejects any non-CommentNone value on a route
	// leaving a decision node — a route is domain-data routing, not a human
	// action, so it has nothing to comment on.
	Comment CommentPolicy
	// Return marks this transition as a return to an earlier task node the
	// case already occupied (a bureaucratic sign-off sending a case back to
	// a stage it already passed through, for example). Engine.Available
	// offers it, and Engine.Move accepts it, ONLY if the case previously
	// occupied To — determined from the case's own event history, never
	// merely because To is reachable in the Definition's graph — so a
	// return is never offered (and never accepted) for a node the case
	// happened to skip. A return whose To the case never occupied fails
	// Move with ErrReturnNotVisited, without mutating the case or
	// appending any event. Return combines with Guard (both must pass) and
	// with Comment (a return will typically be CommentRequired, so the
	// rejection's reason is recorded) exactly like any ordinary
	// transition; see Engine.Move's doc comment for the exact check
	// ordering. Definition.Validate rejects a return transition that
	// originates from a decision node (a route routes automatically on
	// domain data; a return is a human action), that targets a terminal
	// node, or that targets a decision node: a case never rests on a
	// decision node, so a return into one would silently resume automatic
	// routing and could push the case forward instead of back.
	Return bool
}

Transition is a named move from one node to another, optionally gated by a registered guard. When From is a decision node, a Transition is instead called a route: routes are evaluated in declaration order by Engine.Move, and Definition.Validate requires exactly one unguarded route (the default), declared last.

Directories

Path Synopsis
Package migrations embeds the SQL migration(s) that create the workflow_case and workflow_event tables this library owns.
Package migrations embeds the SQL migration(s) that create the workflow_case and workflow_event tables this library owns.
Package postgres provides the PostgreSQL-backed implementation of the workflow.Repository port, built on native pgx.
Package postgres provides the PostgreSQL-backed implementation of the workflow.Repository port, built on native pgx.

Jump to

Keyboard shortcuts

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