Help us shape the future of Go! Take the 2026 Go Developer Survey

approvals

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 12 Imported by: 0

README

go-approvals

tests Go Reference

Multi-step approvals and business workflows for SaaS applications: an expense approved by a manager and then by finance, a contract signed off by legal, an access request that expires if nobody answers. Workflows are plain Go values registered by key; requests snapshot their steps so code changes never break work in flight; separation of duties is on by default; every request is scoped to a tenant; decisions are serialized with row locks; and every transition runs your hooks inside the same transaction, so notifications are enqueued atomically with the state change.

Install

go get github.com/byteminus/go-approvals

Requires Go 1.26 or later. PostgreSQL 16 or later for pgxstore.

Quick start

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"slices"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/pgxstore"
)

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}

	// Role membership comes from your application.
	roles := approvals.ResolverFunc(func(ctx context.Context, actor approvals.Ref, role, tenant string) (bool, error) {
		return role == "finance" && actor.Type == "user" && slices.Contains([]string{"fay", "finn"}, actor.ID), nil
	})
	engine, err := approvals.New(store, approvals.Options{
		Resolver: roles,
		Hooks: []approvals.Hook{func(ctx context.Context, t approvals.Transition, r approvals.Request) error {
			// Runs in the transition's transaction: enqueue notifications here.
			log.Printf("%s: %s by %s", r.ID, t.Kind, t.Actor)
			return nil
		}},
	})
	if err != nil {
		log.Fatal(err)
	}
	err = engine.Register(approvals.Workflow{
		Key: "expense",
		Steps: []approvals.Step{
			{Name: "manager", Approvers: []approvals.Ref{{Type: "user", ID: "maria"}}, Rule: approvals.RuleAny},
			{Name: "finance", Roles: []string{"finance"}, Rule: approvals.RuleQuorum, Quorum: 2, Deadline: 48 * time.Hour},
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	as := func(id string) context.Context {
		ctx := approvals.WithActor(ctx, approvals.Ref{Type: "user", ID: id})
		return approvals.WithTenant(ctx, "acme")
	}
	req, err := engine.Submit(as("eve"), approvals.SubmitInput{
		Workflow: "expense",
		Subject:  approvals.Ref{Type: "expense", ID: "exp_42"},
		Payload:  []byte(`{"amount": 125, "currency": "EUR"}`),
	})
	if err != nil {
		log.Fatal(err)
	}
	for _, id := range []string{"maria", "fay", "finn"} {
		if req, err = engine.Approve(as(id), req.ID, approvals.DecideInput{Comment: "ok"}); err != nil {
			log.Fatal(err)
		}
	}
	fmt.Println(req.Status) // approved
}

Concepts

  • Ref: every party and record is a typed reference, Ref{Type: "user", ID: "42"}, so a user and an API token with the same ID never collide. Requesters, approvers, deciders and whoever cancelled are all Refs. A Ref is either zero or has both fields set.

  • Workflow: a key and an ordered list of steps, defined in code and validated by Engine.Register. Each step has a unique name, approvers (explicit Refs, roles, or both) and a rule:

    • RuleAny: the first eligible approval completes the step.
    • RuleAll: every listed approver must approve. Cannot be combined with roles, because "everyone in a role" cannot be counted reliably.
    • RuleQuorum: Quorum distinct eligible approvals complete the step.
    • An optional Deadline per step: once it passes the request expires.
  • Separation of duties is on by default:

    • the requester cannot decide on their own request, whether listed or through a role, unless the workflow sets AllowSelfApproval;
    • nobody decides twice on one request: an approver of one step is not eligible on the later steps, and one party counts once on a step even when it is both listed and in a role.

    Register rejects a workflow that could never complete under these rules (an exact check: whether distinct approvers can fill every step), and Submit rejects with ErrUnsatisfiable a request that could not complete for its requester (for example when the requester is a step's only approver).

  • Request: a UUID v7, its tenant, the workflow key, a snapshot of the steps, the subject Ref, the requester, a JSON payload, a status (pending, approved, rejected, cancelled, expired), the current step index, the current deadline and timestamps. Only the status, step, deadline and update time ever change; the payload is kept byte for byte.

  • Decision: one approval or rejection per actor per step, with a comment. Repeating it fails with ErrAlreadyDecided. A rejection closes the request; an approval advances it once the rule is satisfied, and the last step approves it. DecideInput.Step optionally names the step the actor is looking at, so a stale click fails with ErrStaleStep instead of counting for the next step.

  • Eligibility: an actor listed on the step, or one for whom your ApproverResolver.HasRole(ctx, actor, role, tenantID) returns true. It is resolved when the actor decides, so removing someone from a role takes effect on their next decision; approvals already recorded still count.

  • Transition: every state change (submitted, step_advanced, approved, rejected, cancelled, expired) is recorded with the actor that caused it and passed to the hooks, exactly once, in the same transaction. A hook error aborts the change. Hooks take the tenant from r.TenantID, not from the application's own context helpers: ExpireDue runs them under the scheduler's context. Engine calls made from a hook resolve the request's tenant on their own.

  • Tenant: WithTenant stamps new requests and scopes every read and every transition to that tenant; another tenant's request is ErrNotFound. A context without a tenant works in the empty tenant "", so forgetting it fails closed.

  • Deadlines: ExpireDue expires overdue requests and is meant for a scheduler; any number of instances may run it at once. A decision or a cancellation on an overdue request expires it on the spot and fails with ErrNotPending, so a late click never counts. All of them take the same row lock, so a race has exactly one outcome.

  • Inbox: PendingFor returns, one page at a time, the requests an actor can decide on now, oldest first.

  • Requests from 0.1.0: 0.1.0 let one approver decide on several steps. The upgrade marks existing requests with PerStepDecisions, which keeps that rule for them so requests in flight can still complete; new requests never have it.

Wiring the actor and tenant

WithActor and WithTenant put the actor and tenant in the context. An application that already keeps them in its own context values, for several Byte Minus packages at once, can point the engine at them instead:

engine, err := approvals.New(store, approvals.Options{
	TenantFrom: tenancy.TenantIDFrom, // same signature, no adapter
	ActorFrom: func(ctx context.Context) (approvals.Ref, bool) {
		u, ok := myapp.UserFrom(ctx)
		return approvals.Ref{Type: "user", ID: u.ID}, ok
	},
})

Paging the inbox

opts := approvals.PageOptions{Limit: 50} // 0 means DefaultPageSize; capped at MaxPageSize
for {
	page, err := engine.PendingFor(ctx, approvals.Ref{Type: "user", ID: "fay"}, opts)
	if err != nil {
		return err
	}
	for _, r := range page.Requests {
		fmt.Println(r.ID, r.Workflow, r.Subject)
	}
	if page.Next == "" {
		break
	}
	opts.Cursor = page.Next
}

Pages use keyset pagination on (submission time, ID), so a page never repeats or skips a request because others were inserted or closed. One pass through the pages is not a snapshot, though: a request whose submission commits late (inside a long caller transaction), or one that advances to a step the actor can decide, keeps its submission time and may sort before the cursor of a pass already under way. Starting again from the first page shows it.

Limits

Input that can come from outside is bounded: payloads to MaxPayloadSize (64 KiB) and MaxPayloadDepth (64 levels of nesting); comments and cancellation reasons to MaxCommentSize (4096 bytes); keys, names, roles, tenant IDs and each half of a Ref to 200 bytes. Text must be valid UTF-8 without NUL bytes. Violations are ErrInvalid. Workflow definitions are capped too: MaxWorkflowSteps (32) steps, MaxWorkflowApprovers (256) explicit approvers and MaxWorkflowRoles (64) roles, each counted over all steps.

PostgreSQL

pgxstore.New(db, opts...) takes a *pgxpool.Pool, *pgx.Conn or pgx.Tx. Tables are named with a prefix, approval_ by default; use pgxstore.WithTablePrefix("billing.approval_") for another prefix or a schema. store.CreateSchema(ctx) applies the DDL below (idempotent), and store.Schema() returns it for migration tools:

CREATE TABLE IF NOT EXISTS "approval_requests" (
	id                  uuid PRIMARY KEY,
	workflow            text NOT NULL CHECK (workflow <> ''),
	steps               jsonb NOT NULL CHECK (
		CASE WHEN jsonb_typeof(steps) = 'array' THEN jsonb_array_length(steps) > 0 ELSE false END),
	allow_self_approval boolean NOT NULL,
	per_step_decisions  boolean NOT NULL,
	subject_type        text NOT NULL CHECK (subject_type <> ''),
	subject_id          text NOT NULL CHECK (subject_id <> ''),
	requester_type      text NOT NULL CHECK (requester_type <> ''),
	requester_id        text NOT NULL CHECK (requester_id <> ''),
	payload             json,
	tenant_id           text NOT NULL,
	status              text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled', 'expired')),
	current_step        integer NOT NULL,
	current_approvers   jsonb NOT NULL,
	current_roles       text[] NOT NULL,
	due_at              timestamptz,
	created_at          timestamptz NOT NULL,
	updated_at          timestamptz NOT NULL,
	CHECK (current_step >= 0 AND current_step <
		CASE WHEN jsonb_typeof(steps) = 'array' THEN jsonb_array_length(steps) ELSE 0 END),
	CHECK (status = 'pending' OR due_at IS NULL)
);

CREATE INDEX IF NOT EXISTS "approval_requests_due_idx" ON "approval_requests" (due_at, id)
	WHERE status = 'pending' AND due_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS "approval_requests_approvers_idx" ON "approval_requests" USING gin (current_approvers jsonb_path_ops)
	WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS "approval_requests_roles_idx" ON "approval_requests" USING gin (current_roles)
	WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS "approval_requests_inbox_idx" ON "approval_requests" (tenant_id, created_at, id)
	WHERE status = 'pending';

CREATE TABLE IF NOT EXISTS "approval_decisions" (
	seq        bigint GENERATED ALWAYS AS IDENTITY,
	id         uuid PRIMARY KEY,
	request_id uuid NOT NULL REFERENCES "approval_requests" (id) ON DELETE CASCADE,
	step       integer NOT NULL CHECK (step >= 0),
	actor_type text NOT NULL CHECK (actor_type <> ''),
	actor_id   text NOT NULL CHECK (actor_id <> ''),
	outcome    text NOT NULL CHECK (outcome IN ('approved', 'rejected')),
	comment    text NOT NULL,
	created_at timestamptz NOT NULL,
	CONSTRAINT "approval_decisions_actor_key" UNIQUE (request_id, step, actor_type, actor_id)
);

CREATE TABLE IF NOT EXISTS "approval_transitions" (
	seq        bigint GENERATED ALWAYS AS IDENTITY,
	id         uuid PRIMARY KEY,
	request_id uuid NOT NULL REFERENCES "approval_requests" (id) ON DELETE CASCADE,
	kind       text NOT NULL CHECK (kind IN ('submitted', 'step_advanced', 'approved', 'rejected', 'cancelled', 'expired')),
	from_step  integer NOT NULL CHECK (from_step >= 0),
	to_step    integer NOT NULL CHECK (to_step >= 0),
	actor_type text NOT NULL,
	actor_id   text NOT NULL,
	comment    text NOT NULL,
	created_at timestamptz NOT NULL,
	CONSTRAINT "approval_transitions_actor_check" CHECK ((actor_type = '') = (actor_id = ''))
);

CREATE INDEX IF NOT EXISTS "approval_transitions_request_idx" ON "approval_transitions" (request_id, seq);

current_approvers and current_roles mirror the current step of the snapshot so inboxes are served from indexes; the store maintains them on every write. The payload is json, not jsonb, so it is kept exactly as submitted. Upgrading from 0.1.0 is described in CHANGELOG.md.

Joining your transaction

Every engine call runs in a store transaction. With WithConn, that transaction begins on the connection your context carries, so it becomes a savepoint inside your own transaction and commits or rolls back with it:

type txKey struct{}

store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
	if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
		return tx
	}
	return nil // use the pool
}))

tx, err := pool.Begin(ctx)
// ... insert the expense row with tx ...
req, err := engine.Submit(context.WithValue(ctx, txKey{}, tx), input)
err = tx.Commit(ctx)

Inside a hook, store.TxFrom(ctx) returns the transaction of the transition, for writing outbox rows or enqueueing jobs atomically with it.

API overview

Call Purpose
approvals.New(store, Options{Resolver, CancelAuthorizer, Hooks, Now, ActorFrom, TenantFrom}) Create an engine.
Register(Workflow) Validate and register a workflow by key.
Submit(ctx, SubmitInput) Start a request for the actor in ctx.
Approve(ctx, id, DecideInput) / Reject(...) Record a decision on the current step.
Cancel(ctx, id, reason) Withdraw a request: the requester, or an actor your CancelAuthorizer allows.
Get(ctx, id) / History(ctx, id) Read a request, its decisions and its transitions.
PendingFor(ctx, actor, PageOptions) One page of the actor's inbox, including role-based eligibility.
ExpireDue(ctx) Expire overdue requests; call it from a scheduler.
WithActor, ActorFrom, WithTenant, TenantFrom Context helpers.

Errors are for errors.Is. Every error wraps one of the family sentinels ErrNotFound, ErrInvalid or ErrConflict, except the permission errors ErrNotEligible and ErrNotAuthorized:

Error Wraps
ErrInvalidWorkflow, ErrUnknownWorkflow, ErrNoActor, ErrUnsatisfiable ErrInvalid
ErrDuplicateWorkflow, ErrNotPending, ErrAlreadyDecided, ErrStaleStep ErrConflict

Stores: memstore (in memory; transactions are serialized and rolled back on error, but cannot join a database transaction) and pgxstore. Implement approvals.Store for anything else and run storetest.Run against it.

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/approvals?sslmode=disable go test -race ./...

Without TEST_DATABASE_URL the PostgreSQL tests are skipped (and fail when CI is set). The storetest conformance suite runs against both stores, including tenant isolation for every operation and the concurrency tests: concurrent approvals close a quorum step exactly once, approval, rejection, cancellation and expiry racing on one request have exactly one outcome, and concurrent sweepers expire each request once. pgxstore adds lock tests that watch pg_stat_activity to prove that competing operations wait for each other and then see the committed state, and that the sweeper skips locked rows. Fuzz targets cover workflow definitions, payloads (including PostgreSQL round trips) and page cursors.

Roadmap

  • Filters for PendingFor (by workflow or subject type).
  • Delegation and out-of-office substitutes.
  • Escalation or reminders before a deadline, instead of only expiry.
  • Hooks for individual decisions that do not change state.
  • Conditional steps (skip a step based on the payload).
  • Purging or archiving closed requests.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

MIT, see LICENSE.

Documentation

Overview

Package approvals runs multi-step approval workflows for SaaS applications, such as an expense approved by a manager and then by finance.

Workflows are defined in code and registered with an Engine by key. Each workflow is an ordered list of steps; a step names its approvers (explicit parties as Refs, roles resolved by the application's ApproverResolver, or both) and a rule: RuleAny, RuleAll or RuleQuorum. A step may carry a deadline, after which the request expires.

Separation of duties is on by default. Requesters cannot decide on their own requests unless the workflow sets AllowSelfApproval, and nobody decides twice on one request: an approver of one step is not eligible on the later ones, and one party counts once on a step even when it is both listed and in a role. Role membership is resolved at the moment of each decision. Requests migrated from 0.1.0 carry PerStepDecisions and keep that release's rule, under which one approver could decide on several steps, so that requests in flight during the upgrade still complete.

Submit creates a Request that stores a snapshot of the workflow's steps, so later code changes never alter requests already in flight; its payload is kept byte for byte. Approve and Reject record one Decision per actor per step; a rejection closes the request, and an approval advances it once the step's rule is satisfied. Cancel withdraws a request, PendingFor pages through an actor's inbox across workflows, History returns the audit trail, and ExpireDue closes requests whose deadline passed; it is safe to run from several instances at once.

Every state change is recorded as a Transition and passed to the configured hooks, exactly once, inside the same store transaction, so applications can enqueue notifications atomically; a hook error aborts the change. Transitions of one request are serialized by a row lock in the store.

The actor performing a call and the tenant it is scoped to travel in the context (WithActor, WithTenant), or come from the application's own context values through Options.ActorFrom and Options.TenantFrom. Every read and every transition is confined to the tenant; a context without a tenant is the empty tenant "", never all tenants.

Errors wrap ErrNotFound, ErrInvalid or ErrConflict, so that applications map them to HTTP statuses the same way as the other Byte Minus packages; ErrNotEligible and ErrNotAuthorized report permission failures.

Storage is pluggable through Store: see the memstore and pgxstore subpackages, and storetest for the conformance suite third-party stores can run.

Example

An expense is approved by the employee's manager, then by two members of the finance team within 48 hours.

package main

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"time"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/memstore"
)

func main() {
	finance := approvals.ResolverFunc(func(_ context.Context, actor approvals.Ref, role, tenant string) (bool, error) {
		return role == "finance" && tenant == "acme" && actor.Type == "user" &&
			slices.Contains([]string{"fay", "finn"}, actor.ID), nil
	})
	notify := func(_ context.Context, t approvals.Transition, r approvals.Request) error {
		// In production, enqueue a notification job here; it commits or
		// rolls back with the transition.
		fmt.Printf("hook: %s (step %d -> %d)\n", t.Kind, t.FromStep, t.ToStep)
		return nil
	}
	engine, err := approvals.New(memstore.New(), approvals.Options{
		Resolver: finance,
		Hooks:    []approvals.Hook{notify},
	})
	if err != nil {
		panic(err)
	}
	err = engine.Register(approvals.Workflow{
		Key: "expense",
		Steps: []approvals.Step{
			{Name: "manager", Approvers: []approvals.Ref{{Type: "user", ID: "maria"}}, Rule: approvals.RuleAny},
			{Name: "finance", Roles: []string{"finance"}, Rule: approvals.RuleQuorum, Quorum: 2, Deadline: 48 * time.Hour},
		},
	})
	if err != nil {
		panic(err)
	}

	as := func(id string) context.Context {
		return approvals.WithTenant(approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: id}), "acme")
	}
	req, err := engine.Submit(as("eve"), approvals.SubmitInput{
		Workflow: "expense",
		Subject:  approvals.Ref{Type: "expense", ID: "exp_42"},
		Payload:  []byte(`{"amount": 125, "currency": "EUR"}`),
	})
	if err != nil {
		panic(err)
	}

	for _, id := range []string{"maria", "fay", "finn"} {
		req, err = engine.Approve(as(id), req.ID, approvals.DecideInput{Comment: "ok"})
		if err != nil {
			panic(err)
		}
		fmt.Printf("%s approved: status %s, step %d\n", id, req.Status, req.CurrentStep)
	}

	_, err = engine.Approve(as("eve"), req.ID, approvals.DecideInput{})
	fmt.Println(err)
	fmt.Println(errors.Is(err, approvals.ErrConflict))

}
Output:
hook: submitted (step 0 -> 0)
hook: step_advanced (step 0 -> 1)
maria approved: status pending, step 1
fay approved: status pending, step 1
hook: approved (step 1 -> 1)
finn approved: status approved, step 1
approvals: request is not pending: status is approved
true

Index

Examples

Constants

View Source
const (
	// DefaultPageSize is the page size PendingFor uses when the limit is 0.
	DefaultPageSize = 50
	// MaxPageSize caps the page size PendingFor returns; larger limits are
	// reduced to it.
	MaxPageSize = 200
)
View Source
const (
	// MaxWorkflowSteps is the most steps a workflow may have.
	MaxWorkflowSteps = 32
	// MaxWorkflowApprovers is the most explicit approvers a workflow may
	// list, counted over all its steps.
	MaxWorkflowApprovers = 256
	// MaxWorkflowRoles is the most roles a workflow may name, counted over
	// all its steps.
	MaxWorkflowRoles = 64
)

Caps on a workflow definition, enforced by Register. Every request carries a snapshot of its workflow's steps, so they also bound the size of each stored request and the cost of checking that a definition can complete.

View Source
const MaxCommentSize = 4096

MaxCommentSize is the largest decision comment or cancellation reason, in bytes, that the engine accepts.

View Source
const MaxPayloadDepth = 64

MaxPayloadDepth is the deepest nesting of arrays and objects that Submit accepts in a payload. It keeps decoders that recurse, PostgreSQL's among them, far from their stack limits.

View Source
const MaxPayloadSize = 64 << 10

MaxPayloadSize is the largest request payload, in bytes, that Submit accepts. Payloads describe the business object under approval; anything larger belongs in the application's own tables, referenced by Subject.

Variables

View Source
var (
	// ErrNotFound reports a request that does not exist or belongs to
	// another tenant; the two cases are deliberately indistinguishable.
	ErrNotFound error = baseError("approvals: request not found")
	// ErrInvalid reports input that failed validation. The message names
	// the field and the reason.
	ErrInvalid error = baseError("approvals: invalid input")
	// ErrConflict reports a uniqueness violation, or a request whose state
	// no longer allows the operation.
	ErrConflict error = baseError("approvals: conflict")
)

The three sentinels every Byte Minus package exports, so that applications map all of them to HTTP statuses the same way. Every other error below wraps one of them, except ErrNotEligible and ErrNotAuthorized, which report a permission failure rather than bad input or state.

View Source
var (
	// ErrInvalidWorkflow reports a workflow definition rejected by
	// Register. It wraps ErrInvalid.
	ErrInvalidWorkflow error = &kindError{"approvals: invalid workflow", ErrInvalid}
	// ErrDuplicateWorkflow reports a second Register call with the same
	// key. It wraps ErrConflict.
	ErrDuplicateWorkflow error = &kindError{"approvals: workflow already registered", ErrConflict}
	// ErrUnknownWorkflow reports a Submit for a key that was never
	// registered. It wraps ErrInvalid.
	ErrUnknownWorkflow error = &kindError{"approvals: unknown workflow", ErrInvalid}
	// ErrNoActor reports a call that needs an actor (see WithActor) without
	// one. It wraps ErrInvalid.
	ErrNoActor error = &kindError{"approvals: no actor", ErrInvalid}
	// ErrUnsatisfiable reports a submission whose workflow could never
	// complete, typically because separation of duties excludes the only
	// eligible approver. It wraps ErrInvalid.
	ErrUnsatisfiable error = &kindError{"approvals: workflow cannot be satisfied for this requester", ErrInvalid}
	// ErrNotPending reports an action on a request that is already closed.
	// It wraps ErrConflict.
	ErrNotPending error = &kindError{"approvals: request is not pending", ErrConflict}
	// ErrAlreadyDecided reports a second decision by the same actor on the
	// same step: a replay. It wraps ErrConflict.
	ErrAlreadyDecided error = &kindError{"approvals: actor already decided on this step", ErrConflict}
	// ErrStaleStep reports a decision aimed at a step that is no longer the
	// current one. It wraps ErrConflict.
	ErrStaleStep error = &kindError{"approvals: request is no longer at that step", ErrConflict}
	// ErrNotEligible reports a decision by an actor who may not decide on
	// the current step.
	ErrNotEligible error = baseError("approvals: actor is not eligible")
	// ErrNotAuthorized reports a cancellation by an actor who may not
	// cancel.
	ErrNotAuthorized error = baseError("approvals: actor is not authorized")
)

Functions

func TenantFrom

func TenantFrom(ctx context.Context) (string, bool)

TenantFrom returns the tenant ID stored by WithTenant. The boolean is false when none was set.

func WithActor

func WithActor(ctx context.Context, actor Ref) context.Context

WithActor returns a copy of ctx carrying the party performing engine calls: the requester for Submit and Cancel, the approver for Approve and Reject. Applications that already keep the user in the context can set Options.ActorFrom instead.

func WithTenant

func WithTenant(ctx context.Context, tenantID string) context.Context

WithTenant returns a copy of ctx scoped to a tenant. Submit stamps new requests with it, and every read and every transition only sees that tenant's requests. Without a tenant the engine works in the empty tenant "", which suits single-tenant applications; a multi-tenant application that forgets to set one therefore sees nothing rather than everything. Applications that already keep the tenant in the context can set Options.TenantFrom instead.

Types

type ApproverResolver

type ApproverResolver interface {
	HasRole(ctx context.Context, actor Ref, role, tenantID string) (bool, error)
}

ApproverResolver answers role membership questions for the application. Applications implement it when a workflow names roles.

HasRole reports whether actor holds role within tenantID, the request's tenant ("" for requests submitted without one). The engine calls it at the moment an actor decides, inside the decision's transaction, so a membership revoked while a request is in flight takes effect on the next decision; approvals already recorded stand. It must be safe for concurrent use and should honour ctx cancellation. An error aborts the operation that asked.

type CancelAuthorizer

type CancelAuthorizer interface {
	CanCancel(ctx context.Context, actor Ref, r Request) (bool, error)
}

CancelAuthorizer lets the application allow parties other than the requester, such as administrators, to cancel a request. Applications implement it; the requester may always cancel their own pending request.

CanCancel reports whether actor may cancel r, which is locked for the duration of the call and belongs to the caller's tenant. It must be safe for concurrent use. An error aborts the cancellation.

type CancelAuthorizerFunc

type CancelAuthorizerFunc func(ctx context.Context, actor Ref, r Request) (bool, error)

CancelAuthorizerFunc adapts a function to CancelAuthorizer.

func (CancelAuthorizerFunc) CanCancel

func (f CancelAuthorizerFunc) CanCancel(ctx context.Context, actor Ref, r Request) (bool, error)

CanCancel calls f.

type DecideInput

type DecideInput struct {
	// Comment is stored with the decision; at most MaxCommentSize bytes.
	Comment string
	// Step, when set, must name the request's current step or the decision
	// fails with ErrStaleStep. It protects a click meant for one step from
	// counting for the next after the request moved on.
	Step string
}

DecideInput carries the details of an approval or rejection.

type Decision

type Decision struct {
	ID        uuid.UUID
	RequestID uuid.UUID
	Step      int
	Actor     Ref
	Outcome   Outcome
	Comment   string
	At        time.Time
}

Decision is one actor's approval or rejection on one step of a request. Each actor decides at most once per request: once on a step, and never on a later step after approving an earlier one.

type DueQuery

type DueQuery struct {
	Now   time.Time
	After *Keyset
	Limit int
}

DueQuery pages through overdue requests for LockDue. When After is set only requests ordered after it by (DueAt, ID) are returned.

type Engine

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

Engine runs workflows against a Store. It is safe for concurrent use; transitions of the same request are serialized by the store's row lock.

func New

func New(store Store, opts Options) (*Engine, error)

New returns an engine backed by store. It returns an error wrapping ErrInvalid when store is nil or a hook is nil.

func (*Engine) Approve

func (e *Engine) Approve(ctx context.Context, id uuid.UUID, in DecideInput) (Request, error)

Approve records an approval by the actor in ctx on the request's current step. Once the step's rule is satisfied the request advances, or is approved if it was the last step.

The actor must be eligible on the current step (listed, or holding one of its roles now), must not be the requester unless the workflow allows self-approval, and must not have decided on the request before: ErrAlreadyDecided reports a repeated decision on the same step and ErrNotEligible one on a later step after approving an earlier one, except on requests with PerStepDecisions (migrated from 0.1.0). A request of another tenant is ErrNotFound. A request whose deadline has passed is expired instead and ErrNotPending is returned.

Example (SeparationOfDuties)

Separation of duties: the requester cannot approve their own request, and nobody approves two steps of the same request.

package main

import (
	"context"
	"fmt"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/memstore"
)

func main() {
	lee, sam, kim := approvals.Ref{Type: "user", ID: "lee"}, approvals.Ref{Type: "user", ID: "sam"}, approvals.Ref{Type: "user", ID: "kim"}
	engine, _ := approvals.New(memstore.New(), approvals.Options{})
	_ = engine.Register(approvals.Workflow{
		Key: "refund",
		Steps: []approvals.Step{
			{Name: "lead", Approvers: []approvals.Ref{lee, sam}, Rule: approvals.RuleAny},
			{Name: "audit", Approvers: []approvals.Ref{lee, sam, kim}, Rule: approvals.RuleAny},
		},
	})
	req, _ := engine.Submit(approvals.WithActor(context.Background(), sam),
		approvals.SubmitInput{Workflow: "refund", Subject: approvals.Ref{Type: "refund", ID: "r1"}})
	_, err := engine.Approve(approvals.WithActor(context.Background(), sam), req.ID, approvals.DecideInput{})
	fmt.Println(err)

	req2, _ := engine.Submit(approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: "ann"}),
		approvals.SubmitInput{Workflow: "refund", Subject: approvals.Ref{Type: "refund", ID: "r2"}})
	_, _ = engine.Approve(approvals.WithActor(context.Background(), lee), req2.ID, approvals.DecideInput{})
	_, err = engine.Approve(approvals.WithActor(context.Background(), lee), req2.ID, approvals.DecideInput{})
	fmt.Println(err)
}
Output:
approvals: actor is not eligible: requesters cannot decide on their own requests
approvals: actor is not eligible: user:lee already approved step 0 of this request

func (*Engine) Cancel

func (e *Engine) Cancel(ctx context.Context, id uuid.UUID, reason string) (Request, error)

Cancel withdraws a pending request. The actor in ctx must be the requester or be allowed by the CancelAuthorizer; otherwise ErrNotAuthorized is returned. reason, at most MaxCommentSize bytes, is recorded on the transition. A request of another tenant is ErrNotFound, a closed one ErrNotPending, and one whose deadline has passed is expired instead, with ErrNotPending, exactly as a decision would.

Example

Cancel withdraws a request; parties other than the requester need the CancelAuthorizer's permission.

package main

import (
	"context"
	"fmt"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/memstore"
)

func main() {
	admins := approvals.CancelAuthorizerFunc(func(_ context.Context, actor approvals.Ref, _ approvals.Request) (bool, error) {
		return actor == approvals.Ref{Type: "user", ID: "root"}, nil
	})
	engine, _ := approvals.New(memstore.New(), approvals.Options{CancelAuthorizer: admins})
	_ = engine.Register(approvals.Workflow{
		Key:   "po",
		Steps: []approvals.Step{{Name: "buyer", Approvers: []approvals.Ref{{Type: "user", ID: "bo"}}, Rule: approvals.RuleAny}},
	})
	as := func(id string) context.Context {
		return approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: id})
	}
	req, _ := engine.Submit(as("ivan"), approvals.SubmitInput{Workflow: "po", Subject: approvals.Ref{Type: "po", ID: "7"}})
	_, err := engine.Cancel(as("bo"), req.ID, "")
	fmt.Println(err)
	req, err = engine.Cancel(as("root"), req.ID, "duplicate order")
	fmt.Println(req.Status, err)
}
Output:
approvals: actor is not authorized: user:bo may not cancel this request
cancelled <nil>

func (*Engine) ExpireDue

func (e *Engine) ExpireDue(ctx context.Context) (int, error)

ExpireDue expires every pending request whose deadline has passed and returns how many it expired. It is meant to be called periodically by a scheduler, from as many instances as convenient: each batch locks its requests and skips those another instance or a decision holds, so a request is expired exactly once. It works across all tenants regardless of ctx; hooks see a context carrying the request's tenant through WithTenant, and r.TenantID. A hook error holds back only that request (it stays pending for the next run); the errors are joined and returned after the remaining requests are processed. Cancelling ctx stops the sweep.

Example

ExpireDue is meant for a periodic job; requests past their step deadline are expired and their hooks run.

package main

import (
	"context"
	"fmt"
	"time"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/memstore"
)

func main() {
	now := time.Date(2026, 10, 23, 9, 0, 0, 0, time.UTC)
	engine, _ := approvals.New(memstore.New(), approvals.Options{Now: func() time.Time { return now }})
	_ = engine.Register(approvals.Workflow{
		Key: "access",
		Steps: []approvals.Step{{
			Name: "owner", Approvers: []approvals.Ref{{Type: "user", ID: "olga"}}, Rule: approvals.RuleAny, Deadline: 24 * time.Hour,
		}},
	})
	req, _ := engine.Submit(approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: "ivan"}),
		approvals.SubmitInput{Workflow: "access", Subject: approvals.Ref{Type: "repo", ID: "billing"}})

	now = now.Add(25 * time.Hour)
	n, err := engine.ExpireDue(context.Background())
	fmt.Println(n, err)
	req, _ = engine.Get(context.Background(), req.ID)
	fmt.Println(req.Status)
}
Output:
1 <nil>
expired

func (*Engine) Get

func (e *Engine) Get(ctx context.Context, id uuid.UUID) (Request, error)

Get returns a request of the tenant in ctx. A request of another tenant is reported as ErrNotFound, like a missing one. No actor is required.

func (*Engine) History

func (e *Engine) History(ctx context.Context, id uuid.UUID) (History, error)

History returns a request's decisions and transitions, with the same tenant rule as Get. A request has at most one transition per step plus two, and one decision per eligible approver and step.

func (*Engine) PendingFor

func (e *Engine) PendingFor(ctx context.Context, actor Ref, opts PageOptions) (RequestPage, error)

PendingFor returns one page of the requests, across all workflows of the tenant in ctx, that actor can decide on right now: those whose current step lists the actor or one of the actor's roles, excluding requests the actor already decided on, their own requests unless self-approval is allowed, and requests past their deadline. Requests are ordered by submission time, oldest first; pass the returned Next as opts.Cursor for the following page.

One pass through the pages is not a snapshot. A request whose submission commits late (inside a long caller transaction), or one that advances to a step the actor can decide, keeps its submission time, so it may sort before the cursor and be missed by a pass already under way; starting again from the first page shows it.

It returns ErrNoActor for a zero actor and ErrInvalid for a half-empty actor, a negative limit or a malformed cursor. Role eligibility is resolved by asking the ApproverResolver about each distinct role that the tenant's open requests are waiting on, so the cost grows with the number of such roles rather than with the number of requests.

Example

PendingFor returns an actor's inbox one page at a time; Next is the cursor of the following page and is empty on the last one.

package main

import (
	"context"
	"fmt"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/memstore"
)

func main() {
	olga := approvals.Ref{Type: "user", ID: "olga"}
	engine, _ := approvals.New(memstore.New(), approvals.Options{})
	_ = engine.Register(approvals.Workflow{
		Key:   "access",
		Steps: []approvals.Step{{Name: "owner", Approvers: []approvals.Ref{olga}, Rule: approvals.RuleAny}},
	})
	ivan := approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: "ivan"})
	for _, repo := range []string{"billing", "search", "web"} {
		_, _ = engine.Submit(ivan, approvals.SubmitInput{Workflow: "access", Subject: approvals.Ref{Type: "repo", ID: repo}})
	}

	opts := approvals.PageOptions{Limit: 2}
	for {
		page, err := engine.PendingFor(context.Background(), olga, opts)
		if err != nil {
			panic(err)
		}
		for _, r := range page.Requests {
			fmt.Println(r.Subject.ID)
		}
		if page.Next == "" {
			break
		}
		opts.Cursor = page.Next
	}
}
Output:
billing
search
web

func (*Engine) Register

func (e *Engine) Register(w Workflow) error

Register validates w and makes it available to Submit. It returns an error wrapping ErrInvalidWorkflow for a malformed definition, including one no requester could ever complete, and ErrDuplicateWorkflow when the key is taken. The engine keeps its own copy of w.

func (*Engine) Reject

func (e *Engine) Reject(ctx context.Context, id uuid.UUID, in DecideInput) (Request, error)

Reject records a rejection by the actor in ctx, which closes the request. Eligibility, errors and deadlines are as for Approve.

func (*Engine) Submit

func (e *Engine) Submit(ctx context.Context, in SubmitInput) (Request, error)

Submit starts a request for the actor in ctx, in the tenant of ctx. It returns ErrNoActor without an actor, ErrUnknownWorkflow for an unregistered key, ErrUnsatisfiable when separation of duties leaves a step without enough approvers, and ErrInvalid for malformed input.

type History

type History struct {
	Decisions   []Decision
	Transitions []Transition
}

History is the audit trail of a request, each list in the order it was recorded.

type Hook

type Hook func(ctx context.Context, t Transition, r Request) error

Hook observes a transition. Hooks run inside the transaction that records the transition, after it is written, exactly once per transition, so they can enqueue notifications or update application rows atomically with it; r is a copy of the request as it stands after the transition. Returning an error aborts the whole transition.

Take the tenant from r.TenantID, not from the application's own context helpers: ExpireDue runs hooks for every tenant under the scheduler's context, so whatever the application's helpers read there is the scheduler's tenant. Engine calls made from a hook with its ctx (Get, History, PendingFor) resolve the request's tenant, whatever Options.TenantFrom is installed.

type Keyset added in v0.2.0

type Keyset struct {
	At time.Time
	ID uuid.UUID
}

Keyset is a position in a list ordered by a time and then by ID, which makes the order unique even when times are equal. Stores page with it: a query with After set returns only rows ordered strictly after it.

type Options

type Options struct {
	// Resolver answers role membership. Workflows that use roles cannot be
	// registered without one.
	Resolver ApproverResolver
	// CancelAuthorizer, when set, is consulted for cancellations by actors
	// other than the requester.
	CancelAuthorizer CancelAuthorizer
	// Hooks run, in order, on every transition. They must not be nil.
	Hooks []Hook
	// Now returns the current time. Nil means time.Now.
	Now func() time.Time
	// ActorFrom returns the party performing the operation. Nil means the
	// package-level ActorFrom, which reads the value stored by WithActor.
	ActorFrom func(ctx context.Context) (Ref, bool)
	// TenantFrom returns the tenant the operation belongs to. Nil means the
	// package-level TenantFrom. When it reports no tenant, the engine
	// works in the empty tenant "".
	TenantFrom func(ctx context.Context) (string, bool)
}

Options configures an Engine. The zero value is valid for workflows that only name explicit approvers.

Example (ContextLookups)

Options.ActorFrom and Options.TenantFrom let the engine read the actor and tenant the application already keeps in its context, so they are stated once for every package that needs them.

package main

import (
	"context"
	"fmt"

	approvals "github.com/byteminus/go-approvals"
	"github.com/byteminus/go-approvals/memstore"
)

type sessionKey struct{}

type session struct {
	UserID, OrgID string
}

func main() {
	engine, _ := approvals.New(memstore.New(), approvals.Options{
		ActorFrom: func(ctx context.Context) (approvals.Ref, bool) {
			s, ok := ctx.Value(sessionKey{}).(session)
			return approvals.Ref{Type: "user", ID: s.UserID}, ok
		},
		TenantFrom: func(ctx context.Context) (string, bool) {
			s, ok := ctx.Value(sessionKey{}).(session)
			return s.OrgID, ok
		},
	})
	_ = engine.Register(approvals.Workflow{
		Key:   "leave",
		Steps: []approvals.Step{{Name: "manager", Approvers: []approvals.Ref{{Type: "user", ID: "max"}}, Rule: approvals.RuleAny}},
	})
	ctx := context.WithValue(context.Background(), sessionKey{}, session{UserID: "eve", OrgID: "acme"})
	req, _ := engine.Submit(ctx, approvals.SubmitInput{Workflow: "leave", Subject: approvals.Ref{Type: "leave", ID: "l1"}})
	fmt.Println(req.Requester, req.TenantID)
}
Output:
user:eve acme

type Outcome

type Outcome string

Outcome is what an actor decided.

const (
	// OutcomeApproved records an approval.
	OutcomeApproved Outcome = "approved"
	// OutcomeRejected records a rejection.
	OutcomeRejected Outcome = "rejected"
)

type PageOptions added in v0.2.0

type PageOptions struct {
	// Limit is the page size: 0 means DefaultPageSize, and values above
	// MaxPageSize are capped. A negative limit is rejected with ErrInvalid.
	Limit int
	// Cursor is the Next value of the previous page, or empty for the
	// first page. It is opaque; a malformed cursor is rejected with
	// ErrInvalid.
	Cursor string
}

PageOptions selects one page of a list. The zero value is the first page of DefaultPageSize items.

type PendingQuery

type PendingQuery struct {
	TenantID string
	Actor    Ref
	Roles    []string
	Now      time.Time
	After    *Keyset
	Limit    int
}

PendingQuery selects one page of an actor's inbox. A request matches when it belongs to TenantID; it is pending and its DueAt is zero or after Now; Actor is not its requester, unless it allows self-approval; Actor has made no decision on it at all, so that nobody approves two steps (or, when the request has PerStepDecisions, none on its current step); and Actor is listed on its current step or Roles contains one of the current step's roles. When After is set, only requests ordered after it by (CreatedAt, ID) match.

type Ref

type Ref struct {
	Type string `json:"type"`
	ID   string `json:"id"`
}

Ref identifies a record or a party by type and ID, for example Ref{Type: "expense", ID: "exp_42"} for a subject or Ref{Type: "user", ID: "42"} for an actor. Typing every reference keeps "user 42" and "api_token 42" apart. A stored Ref is either zero or has both fields set; the engine rejects a half-empty Ref with ErrInvalid.

Example
package main

import (
	"fmt"

	approvals "github.com/byteminus/go-approvals"
)

func main() {
	r := approvals.Ref{Type: "expense", ID: "exp_42"}
	fmt.Println(r, r.IsZero(), approvals.Ref{}.IsZero())
}
Output:
expense:exp_42 false true

func ActorFrom

func ActorFrom(ctx context.Context) (Ref, bool)

ActorFrom returns the actor stored by WithActor. The boolean is false when none was set or the stored Ref is zero. A half-empty Ref is returned as set, so that the engine rejects it with ErrInvalid instead of treating it as missing.

func (Ref) IsZero added in v0.2.0

func (r Ref) IsZero() bool

IsZero reports whether both fields are empty.

func (Ref) String added in v0.2.0

func (r Ref) String() string

String returns "type:id". It is for logs and display only: a type that contains ":" makes it ambiguous, so never use it as a key.

type Request

type Request struct {
	ID                uuid.UUID
	TenantID          string
	Workflow          string
	Steps             []Step
	AllowSelfApproval bool
	Subject           Ref
	Requester         Ref
	// PerStepDecisions marks a request submitted under 0.1.0, which let one
	// approver decide on several steps of the same request. Such requests
	// keep that rule, so that requests in flight during the upgrade can
	// still complete; the upgrade SQL sets it on every existing row.
	// Requests submitted now always have it false, and on them nobody
	// approves twice.
	PerStepDecisions bool
	// Payload is optional application data, stored as JSON exactly as
	// submitted, byte for byte. Nil means no payload.
	Payload json.RawMessage
	Status  Status
	// CurrentStep indexes Steps. Once the request is closed it keeps the
	// index of the step that was open when it closed.
	CurrentStep int
	// DueAt is when the current step's deadline passes; zero means no
	// deadline. It is always zero once the request is closed.
	DueAt     time.Time
	CreatedAt time.Time
	UpdatedAt time.Time
}

Request is one run of a workflow. Steps and AllowSelfApproval are a snapshot taken at submission, so changing or removing a workflow in code never alters requests already in flight. Workflow, Steps, AllowSelfApproval, PerStepDecisions, Subject, Requester, Payload, TenantID and CreatedAt never change after submission; stores persist only Status, CurrentStep, DueAt and UpdatedAt on update.

func (Request) Clone

func (r Request) Clone() Request

Clone returns a deep copy of r, so that the copy can be modified without affecting the original.

type RequestPage added in v0.2.0

type RequestPage struct {
	// Requests holds at most the requested number of requests.
	Requests []Request
	// Next is the cursor of the following page, or empty when this page is
	// the last.
	Next string
}

RequestPage is one page of requests.

type ResolverFunc

type ResolverFunc func(ctx context.Context, actor Ref, role, tenantID string) (bool, error)

ResolverFunc adapts a function to ApproverResolver.

Example
package main

import (
	"context"
	"fmt"

	approvals "github.com/byteminus/go-approvals"
)

func main() {
	admins := approvals.ResolverFunc(func(_ context.Context, actor approvals.Ref, role, _ string) (bool, error) {
		return role == "admin" && actor == approvals.Ref{Type: "user", ID: "root"}, nil
	})
	ok, _ := admins.HasRole(context.Background(), approvals.Ref{Type: "user", ID: "root"}, "admin", "")
	fmt.Println(ok)
}
Output:
true

func (ResolverFunc) HasRole

func (f ResolverFunc) HasRole(ctx context.Context, actor Ref, role, tenantID string) (bool, error)

HasRole calls f.

type RoleQuery

type RoleQuery struct {
	TenantID string
	Now      time.Time
}

RoleQuery scopes ListPendingRoles.

type Rule

type Rule string

Rule decides when a step has collected enough approvals.

const (
	// RuleAny completes the step on the first eligible approval.
	RuleAny Rule = "any"
	// RuleAll completes the step once every explicit approver has approved.
	// Steps using it may not list roles, because role membership is open
	// ended and "everyone in a role" cannot be counted reliably.
	RuleAll Rule = "all"
	// RuleQuorum completes the step once Step.Quorum distinct eligible
	// actors have approved.
	RuleQuorum Rule = "quorum"
)

type Status

type Status string

Status is the lifecycle state of a request.

const (
	// StatusPending means the request is waiting for decisions.
	StatusPending Status = "pending"
	// StatusApproved means every step was satisfied.
	StatusApproved Status = "approved"
	// StatusRejected means an eligible actor rejected the request.
	StatusRejected Status = "rejected"
	// StatusCancelled means the requester or an authorized actor withdrew it.
	StatusCancelled Status = "cancelled"
	// StatusExpired means the current step's deadline passed.
	StatusExpired Status = "expired"
)

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is one of the defined statuses.

type Step

type Step struct {
	// Name identifies the step within its workflow; names are unique per
	// workflow and let callers guard decisions against a step that has
	// already moved on (see DecideInput.Step).
	Name string
	// Approvers lists the parties that may decide on this step.
	Approvers []Ref
	// Roles lists roles whose members may decide on this step.
	Roles []string
	// Rule decides when the step is satisfied.
	Rule Rule
	// Quorum is the number of approvals RuleQuorum requires. It must be zero
	// for the other rules.
	Quorum int
	// Deadline, when positive, is how long the step may stay open. Once it
	// passes the request expires. The resulting due time is truncated to
	// the microsecond, PostgreSQL's precision.
	Deadline time.Duration
}

Step is one stage of a workflow. An actor is eligible to decide on the step when it is listed in Approvers or holds one of Roles, as reported by the engine's ApproverResolver at the moment it decides.

type Store

type Store interface {
	// InTx runs fn in a transaction. The context passed to fn carries the
	// transaction: store calls made with it join the transaction, and so do
	// hooks. If fn returns an error the transaction's writes are discarded.
	// A nested InTx joins the outer transaction and, when it fails, discards
	// only its own writes.
	InTx(ctx context.Context, fn func(ctx context.Context) error) error

	// CreateRequest inserts r. It returns ErrConflict when r.ID exists and
	// ErrInvalid when r is malformed: empty workflow; incomplete subject or
	// requester; no steps; an unknown status; CurrentStep out of range; a
	// closed request with a DueAt; a payload that is not valid JSON; or
	// text PostgreSQL cannot store (invalid UTF-8, NUL bytes).
	CreateRequest(ctx context.Context, r Request) error

	// GetRequest returns the request with id in tenantID, or ErrNotFound.
	GetRequest(ctx context.Context, tenantID string, id uuid.UUID) (Request, error)

	// LockRequest returns the request like GetRequest and locks it until the
	// surrounding InTx ends, so that concurrent writers on the same request
	// are serialized. A second LockRequest on the same request from another
	// transaction waits for the first to end and then sees its writes.
	LockRequest(ctx context.Context, tenantID string, id uuid.UUID) (Request, error)

	// UpdateRequest saves the mutable fields of r: Status, CurrentStep, DueAt
	// and UpdatedAt; every other field, the payload included, is ignored.
	// Only pending requests may change: it returns ErrNotFound when r.ID
	// does not exist in r.TenantID, ErrNotPending when the stored request
	// is closed, and ErrInvalid when the new state is malformed.
	UpdateRequest(ctx context.Context, r Request) error

	// CreateDecision inserts d. It returns ErrAlreadyDecided when the actor
	// already decided on that step of that request, ErrConflict when d.ID
	// exists, ErrNotFound when the request does not exist, and ErrInvalid
	// when d is malformed (negative step, incomplete actor, unknown
	// outcome).
	CreateDecision(ctx context.Context, d Decision) error

	// ListDecisions returns the decisions on a request of tenantID in
	// insertion order, or none when there is no such request.
	ListDecisions(ctx context.Context, tenantID string, requestID uuid.UUID) ([]Decision, error)

	// CreateTransition inserts t, with the same error rules as
	// CreateDecision apart from ErrAlreadyDecided. Its Actor may be zero.
	CreateTransition(ctx context.Context, t Transition) error

	// ListTransitions returns the transitions of a request of tenantID in
	// insertion order, or none when there is no such request.
	ListTransitions(ctx context.Context, tenantID string, requestID uuid.UUID) ([]Transition, error)

	// ListPending returns one page of an inbox; see PendingQuery for the
	// matching rules. Requests are ordered by CreatedAt then ID, and at
	// most q.Limit are returned. It returns ErrInvalid when q.Limit is not
	// positive.
	ListPending(ctx context.Context, q PendingQuery) ([]Request, error)

	// ListPendingRoles returns the distinct roles named by the current
	// steps of the pending, not yet overdue requests of q.TenantID, in
	// bytewise order.
	ListPendingRoles(ctx context.Context, q RoleQuery) ([]string, error)

	// LockDue returns, across all tenants, pending requests whose DueAt is
	// set and not after q.Now, ordered by DueAt then ID, at most q.Limit of
	// them, and locks them like LockRequest. It must be called inside InTx.
	// Requests another transaction holds locked are skipped rather than
	// waited for, so that several sweepers never block one another. It
	// returns ErrInvalid when q.Limit is not positive.
	LockDue(ctx context.Context, q DueQuery) ([]Request, error)
}

Store persists requests, decisions and transitions. Applications pick one of the stores in this module (memstore, pgxstore) or implement their own and verify it with the storetest conformance suite.

Implementations must be safe for concurrent use and must enforce the constraints documented on each method, so that every store behaves the same. Every method that reads or changes a request takes its tenant and treats a request of another tenant exactly like a missing one. Times are returned in UTC; a store may truncate them to its precision, but never below a microsecond. Errors are the package sentinels, wrapped: ErrNotFound, ErrInvalid for a record that violates the constraints, ErrConflict for a duplicate ID, and the narrower errors named below.

type SubmitInput

type SubmitInput struct {
	// Workflow is the key of a registered workflow.
	Workflow string
	// Subject identifies the business object being approved. Both fields
	// are required, each at most 200 bytes.
	Subject Ref
	// Payload is optional JSON the application wants kept with the
	// request: at most MaxPayloadSize bytes and MaxPayloadDepth levels of
	// nesting. It is stored as given and never changes afterwards.
	Payload []byte
}

SubmitInput describes a new request.

type Transition

type Transition struct {
	ID        uuid.UUID
	RequestID uuid.UUID
	Kind      TransitionKind
	FromStep  int
	ToStep    int
	Actor     Ref
	Comment   string
	At        time.Time
}

Transition is an entry in a request's history describing a state change. FromStep and ToStep are the step indexes before and after the change; they differ only for TransitionStepAdvanced. Actor is the party that caused it: the requester for a submission, the decider for a decision, whoever cancelled for a cancellation, and zero for an expiry, which the engine makes on its own.

type TransitionKind

type TransitionKind string

TransitionKind names a change in a request's state.

const (
	// TransitionSubmitted records the creation of a request.
	TransitionSubmitted TransitionKind = "submitted"
	// TransitionStepAdvanced records a satisfied step that was not the last.
	TransitionStepAdvanced TransitionKind = "step_advanced"
	// TransitionApproved records the last step being satisfied.
	TransitionApproved TransitionKind = "approved"
	// TransitionRejected records a rejection that closed the request.
	TransitionRejected TransitionKind = "rejected"
	// TransitionCancelled records a cancellation.
	TransitionCancelled TransitionKind = "cancelled"
	// TransitionExpired records a missed deadline.
	TransitionExpired TransitionKind = "expired"
)

func (TransitionKind) Valid

func (k TransitionKind) Valid() bool

Valid reports whether k is one of the defined transition kinds.

type Workflow

type Workflow struct {
	// Key identifies the workflow, for example "expense".
	Key string
	// Steps run in order; the request is approved when the last one is.
	Steps []Step
	// AllowSelfApproval lets the requester decide on their own request. It is
	// off by default to enforce separation of duties. It never lets anyone,
	// requester or not, approve more than one step of the same request.
	AllowSelfApproval bool
}

Workflow is a named, ordered list of steps defined in application code and registered with an Engine.

Directories

Path Synopsis
Package memstore is an in-memory approvals.Store for tests, examples and prototypes.
Package memstore is an in-memory approvals.Store for tests, examples and prototypes.
Package pgxstore is the PostgreSQL implementation of approvals.Store, built on github.com/jackc/pgx/v5.
Package pgxstore is the PostgreSQL implementation of approvals.Store, built on github.com/jackc/pgx/v5.
Package storetest is a conformance suite for approvals.Store implementations.
Package storetest is a conformance suite for approvals.Store implementations.

Jump to

Keyboard shortcuts

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