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 ¶
- Variables
- func CancelForRun(ctx context.Context, st Store, runID uuid.UUID, reason string) (int, error)
- func Decide(ctx context.Context, st Store, id uuid.UUID, decidedByType types.ActorType, ...) (types.ApprovalRequest, error)
- func ExpireStale(ctx context.Context, st Store, olderThan time.Duration) (int, error)
- func RequestApproval(ctx context.Context, st Store, req types.ApprovalRequest) (types.ApprovalRequest, error)
- type Store
Constants ¶
This section is empty.
Variables ¶
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
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 ¶
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.