approval

package
v0.7.2 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package approval implements the ApprovalRequest FSM service.

States: PENDING -> APPROVED | DENIED | EXPIRED | CANCELLED

Transitions are single-direction and fail-closed: any attempt to decide an already-decided approval returns ErrAlreadyDecided. Every state change emits an audit event with actor_type=human (for decisions) or actor_type=system (for expirations and for the terminal-run cancellation cascade, CancelForRun).

Pure business logic: storage is injected via the Store interface so this package can be tested with an in-memory fake.

Index

Constants

This section is empty.

Variables

View Source
var ErrAlreadyDecided = types.ErrApprovalAlreadyDecided

ErrAlreadyDecided is re-exported here for callers that import only this package. It IS the store's sentinel — one value, defined in internal/types — so errors.Is matches whichever layer raised it.

Functions

func CancelForRun added in v0.7.2

func CancelForRun(ctx context.Context, st Store, runID uuid.UUID, reason string) (int, error)

CancelForRun transitions every still-PENDING approval belonging to runID to CANCELLED and returns how many it moved. reason is the transition that ended the run ("run_killed", "run_completed", "run_failed", "run_stopped"), recorded on each row and on the ONE audit event this emits.

It is ExpireStale's shape deliberately: list PENDING, then move each row with the SAME CAS primitive a human decision uses (DecideApproval is WHERE state='PENDING', which IS the FSM guard), so a human deciding concurrently with the kill cascade wins and is never overwritten — ErrAlreadyDecided is a race, not an error. DecidedBy is "system" for the same reason the sweeper's is: nobody decided this, the run ended.

ONE audit row for the batch, carrying the count, rather than one per approval: unlike an expiry sweep (whose rows are independent events spread over days), these all belong to a single run transition an operator reads as one fact, and the row is keyed to the run so it lands in that run's evidence rail. A run with nothing pending emits nothing at all, which is what makes a re-kill idempotent in the audit log as well as in the store.

THE ROW IS EMITTED ON THE FAILURE PATH TOO, carrying however many rows DID move plus the error. The first draft returned the CAS failure before reaching the audit block, so a cancel that moved two approvals and then hit a store error left two rows durably CANCELLED and nothing in the trail — exactly the unexplained emptying of the operator's queue this row exists to explain, and the reason ExpireStale records inside its own loop rather than after it.

func Decide

func Decide(ctx context.Context, st Store, id uuid.UUID, decidedByType types.ActorType, decision types.ApprovalDecision) (types.ApprovalRequest, error)

Decide transitions an existing approval request to decision.State (which the caller sets — APPROVED or DENIED; ExpireStale below bypasses Decide entirely for EXPIRED, since a sweep is not a decision). decision.DecidedBy is the principal that decided and decidedByType is its actor type (human for an OIDC session or a LocalMode operator; system for a bare admin-token caller). The audit event records that exact type so an admin-token decision is not mislabelled as a human approval (invariant 4/6 attribution honesty).

func ExpireStale

func ExpireStale(ctx context.Context, st Store, olderThan time.Duration) (int, error)

ExpireStale transitions all PENDING approvals that were requested before the cutoff (time.Now().UTC().Add(-olderThan)) to EXPIRED and emits one audit event per expiration. Returns the number of approvals expired.

func RequestApproval

func RequestApproval(ctx context.Context, st Store, req types.ApprovalRequest) (types.ApprovalRequest, error)

RequestApproval creates a new PENDING approval, or returns the existing PENDING approval when one already exists for the same run+kind+scope hash (deduplication guard).

Types

type Store

type Store interface {
	// CreateApproval persists a new PENDING approval and returns it.
	CreateApproval(ctx context.Context, a types.ApprovalRequest) (types.ApprovalRequest, error)
	// GetApproval fetches an approval by id.
	GetApproval(ctx context.Context, id uuid.UUID) (types.ApprovalRequest, error)
	// ListApprovals returns approvals filtered by state (empty = all).
	ListApprovals(ctx context.Context, stateFilter types.ApprovalState) ([]types.ApprovalRequest, error)
	// DecideApproval transitions state from PENDING to decision.State; returns
	// ErrAlreadyDecided if the approval is not PENDING.
	DecideApproval(ctx context.Context, id uuid.UUID, decision types.ApprovalDecision) (types.ApprovalRequest, error)
	// Record appends an audit event (approval.decide, approval.expire).
	Record(ctx context.Context, ev types.AuditEvent) error
}

Store is the narrow persistence interface the approval FSM needs. The real implementation is internal/store; tests supply a fake.

Jump to

Keyboard shortcuts

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