approval

package
v1.0.16 Latest Latest
Warning

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

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

Documentation

Overview

Package approval holds items an agent proposed until a person approves or rejects them, expires the ones nobody decided by their deadline, and hands each decided item to the app's own follow-up exactly once.

An agent proposes, a person decides, and the app's code does the work. The package owns the state machine and its races; the app owns the domain:

  • the deadline is an input (Item.ExpiresAt). Trader expires a held trade at the next market close, growth a draft after a week; NextOr and Within only build the time.
  • what happens on approval is the app's FollowUp: re-validate and place an order, or start the next workflow. It runs in the same transaction as the state change.

The flow

item, err := approvals.Propose(ctx, approval.Item{
    Kind: "trade", SubjectRef: "decisions/" + id, Scope: accountID, RunID: runID,
    Payload: payloadJSON, Summary: "BUY 10 AAPL",
    ExpiresAt: approval.NextOr(clock.NextClose(), now, 8*time.Hour),
})

Propose is idempotent on (Scope, Kind, SubjectRef): a retried agent run proposes the same subject again and gets the first item back, whatever state it has reached.

A person then decides, in a human_only rpc (see below):

item, err := approvals.Decide(ctx, approval.Decision{ID: req.Id, Approve: true, By: name},
    func(ctx context.Context, it approval.Item, d approval.Decision) (string, error) {
        if err := revalidate(ctx, it); err != nil {
            return "", &approval.Rejection{Reason: err.Error()} // approved, but refused
        }
        return place(ctx, it) // the order's id, recorded as Outcome
    })

Decide does, in order:

  1. Expire first. An item past its deadline becomes expired, DecidedBy records who tried, and it is returned with a nil error; the follow-up does not run. Trader answers "approve at 17:00" with EXPIRED, and so does this.
  2. Transition with one conditional UPDATE ... WHERE state = 'pending'. Of two concurrent deciders exactly one wins; the other gets FailedPrecondition (svcerr). So the follow-up runs at most once per item, with no process mutex.
  3. Run the follow-up in the same transaction, when the store is on *orm.Client. An error rolls the transition back, and the item stays pending for another try. A *Rejection commits the item as rejected with the reason as Outcome. Only approvals run a follow-up.

ExpireDue expires every pending item past its deadline in one statement and returns them; Cancel withdraws pending items (an account retired, a draft deleted). Reads are lazy-safe: call ExpireDue at the top of a list or briefing rpc, or from a sweeper.

Side effects outside the database

An order sent to a broker or a workflow started over HTTP cannot be rolled back with the transaction. The pattern:

  • the follow-up derives the effect's idempotency key from Item.ID (a client order id, an idempotency key on the start call), so doing it twice is harmless;

  • when the effect fails after the decision, the follow-up returns HandoffIncomplete. The approval commits with HandedOff false;

  • Redrive re-runs the follow-up for every approved item with HandedOff false, each in its own transaction, until it reports success:

    done, err := approvals.Redrive(ctx, approval.Filter{Scope: accountID}, sameFollowUp)

A crash between the external effect and the commit leaves the item approved and not handed off, and Redrive picks it up the same way. Make the follow-up idempotent on Item.ID and a retry cannot double the effect.

Who may decide

The package never checks identity. Declare the deciding rpc human_only (forge's (forge.v1.method).human_only option, enforced by agentrun.HumanOnlyInterceptor and linted by forgeconv-human-only) so an agent's key is refused before the handler runs, and pass the person's name as Decision.By. The package is independent of agentrun; they compose in the handler:

// rpc ApproveItem(ApproveItemRequest) returns (ApproveItemResponse) {
//   option (forge.v1.method) = { human_only: true };
// }
func (s *Service) ApproveItem(ctx context.Context, req *connect.Request[pb.ApproveItemRequest]) (...) {
    by := personName(ctx) // from agentrun.RequireHuman, or your claims
    item, err := s.approvals.Decide(ctx, approval.Decision{ID: req.Msg.Id, Approve: true, By: by}, s.followUp)
    ...
}

Item.RunID links an item to the agentrun run that proposed it. To record the decision in forge's activity stream so it commits with the decision, declare `writer: HANDLER` on the rpc and call activity.Record inside the follow-up, through the same *orm.Client; the event then commits or rolls back with the decision.

A sweeper

Nothing needs a background job, but one keeps expiry timely for people reading a dashboard. Under forge's pkg/lease, only one replica sweeps:

locker.Run(ctx, "approval-sweeper", func(ctx context.Context) error {
    if _, err := approvals.ExpireDue(ctx, ""); err != nil {
        return err
    }
    _, err := approvals.Redrive(ctx, approval.Filter{}, s.followUp)
    return err
})

Storage

Store persists items to the approvals table, whose canonical DDL is SchemaSQL. The table is a migration the project owns, like every other table (see pkg/activity and pkg/audit); the read rpcs are project code over Store.List and Store.Get, because who may read which scope is access control. A pending item always has a deadline: expires_at is NOT NULL, and Propose rejects a zero one. WithSchema names a non-default schema. MemoryStore has the same behaviour for unit tests.

Decide and Redrive open their transaction through DB.RunTx, which joins a transaction the handler already holds, so the whole rpc can be one transaction. Give the follow-up the ctx it receives and write through the same *orm.Client, and its writes commit or roll back with the decision.

Index

Constants

View Source
const (
	DefaultPageSize = 50
	MaxPageSize     = 500
)

Page sizes for List and Redrive.

View Source
const SchemaSQL = `` /* 1332-byte string literal not displayed */

SchemaSQL is the canonical approvals table Store reads and writes, with the indexes its statements use. A project owns the table as one of its migrations: copy this DDL into db/migrations. Keep a hand-written table's columns to this shape.

expires_at is NOT NULL for every row, so a pending item can never be without a deadline.

Variables

View Source
var ErrBadPageToken = errors.New("approval: malformed page token")

ErrBadPageToken is returned for a PageToken List did not issue.

Functions

func At

func At(t time.Time) time.Time

At returns t. It documents that a deadline is a fixed moment.

func HandoffIncomplete

func HandoffIncomplete(cause error) error

HandoffIncomplete is what a FollowUp returns when the approval stands but its external side effect did not complete. The decision commits with HandedOff false and Decide returns nil; the cause is yours to log, since the follow-up is the code that saw it. An approval only: for a rejection it is an ordinary error.

func NextOr

func NextOr(next, now time.Time, fallback time.Duration) time.Time

NextOr returns next, or now+fallback when next is zero: trader's "expire at the next market close, else in eight hours".

func Within

func Within(now time.Time, d time.Duration) time.Time

Within returns the deadline d after now.

Types

type Approvals

type Approvals interface {
	// Propose records a pending item; idempotent on (Scope, Kind, SubjectRef).
	Propose(ctx context.Context, it Item) (Item, error)
	// Get returns one item, or a NotFound error.
	Get(ctx context.Context, id string) (Item, error)
	// List returns the items matching f, newest first.
	List(ctx context.Context, f Filter) (Page, error)
	// ExpireDue moves pending items past their deadline to expired.
	ExpireDue(ctx context.Context, scope string) ([]Item, error)
	// Cancel withdraws the pending items matching f.
	Cancel(ctx context.Context, f Filter, note string) (int, error)
	// Decide records a person's verdict and runs the follow-up for an approval.
	Decide(ctx context.Context, d Decision, fn FollowUp) (Item, error)
	// Redrive re-runs the follow-up for approved items not yet handed off.
	Redrive(ctx context.Context, f Filter, fn FollowUp) ([]Item, error)
}

Approvals is the surface an app's handlers and sweepers depend on. Store is the Postgres implementation and MemoryStore the in-memory one for tests; take the interface where you want to swap them.

type DB

type DB interface {
	Exec(ctx context.Context, query string, args ...any) (sql.Result, error)
	Query(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	RunTx(ctx context.Context, policy orm.TxPolicy, fn func(ctx context.Context) error) error
}

DB is the database handle a Store runs statements through. *orm.Client satisfies it: its RunTx opens the transaction Decide runs in, and a statement issued with a ctx carrying that transaction joins it, so the follow-up's own writes (through the same client) commit or roll back with the decision.

type Decision

type Decision struct {
	ID      string
	Approve bool
	// By is who decided. The package does not check it: the rpc that calls
	// Decide is declared human_only and passes the name it extracted.
	By   string
	Note string
}

Decision is a person's verdict on one item.

type Filter

type Filter struct {
	IDs        []string
	Scope      string
	Kind       string
	SubjectRef string
	RunID      string
	States     []State
	// PageSize bounds List (default [DefaultPageSize], at most [MaxPageSize])
	// and Redrive (the number of items attempted per call).
	PageSize int
	// PageToken continues a List from a previous page's NextPageToken.
	PageToken string
}

Filter selects items for List, Cancel and Redrive. Set fields are AND-combined; an empty field does not filter.

type FollowUp

type FollowUp func(ctx context.Context, it Item, d Decision) (outcome string, err error)

FollowUp is the app's work once a person decides: re-validate and execute (trader), or start the next workflow (growth). It receives the item as it stands after the transition, and returns what to record as Item.Outcome.

With a store on *orm.Client it runs in the same transaction as the state change, so a returned error rolls the transition back and leaves the item pending. Two return values are not errors in that sense:

  • a *Rejection turns an approval into a recorded rejection (the re-check refused it). Writes the follow-up made in the transaction are discarded; the rejection itself is committed.
  • a HandoffIncomplete error commits the approval but leaves Item.HandedOff false, so Redrive runs the follow-up again. Use it when the work has a side effect outside the database that failed.

A follow-up must be safe to run again: Redrive re-runs it, and a crash after an external effect but before commit does too. Derive any external idempotency key from Item.ID.

type HandoffError

type HandoffError struct{ Cause error }

HandoffError is the error HandoffIncomplete builds.

func (*HandoffError) Error

func (e *HandoffError) Error() string

func (*HandoffError) Unwrap

func (e *HandoffError) Unwrap() error

type Item

type Item struct {
	ID    string
	Scope string // tenant, account or whatever partitions the app; "" if none
	// Kind is the app's label for what is being approved: "trade",
	// "publish_variant".
	Kind string
	// SubjectRef names the app's own row this approval is about
	// ("decisions/01H..."). With Scope and Kind it makes Propose idempotent.
	SubjectRef string
	RunID      string // the agent run that proposed it; "" if none
	// Payload is opaque JSON shown to the approver. It is never interpreted.
	Payload []byte
	Summary string // one line for lists and notifications
	State   State
	// ExpiresAt is the deadline after which nobody can approve the item. It is
	// required: a pending item with no deadline waits forever.
	ExpiresAt time.Time
	DecidedBy string
	DecidedAt *time.Time
	Note      string
	// Outcome is what the follow-up reported ("filled", "rejected on
	// re-validation: price moved") or, for a cancelled item, why.
	Outcome string
	// HandedOff reports that the follow-up completed. An approved item with
	// HandedOff false is one Redrive will run again.
	HandedOff bool
	CreatedAt time.Time
	UpdatedAt time.Time
}

Item is one thing an agent proposed and a person has yet to decide.

type MemoryStore

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

MemoryStore is an in-memory Approvals with the same semantics as Store, for unit tests of code that proposes and decides. It is safe for concurrent use. A follow-up must not call back into the same MemoryStore: Decide and Redrive hold its lock while the follow-up runs, which is what makes the transition and the follow-up one atomic step.

func NewMemoryStore

func NewMemoryStore(opts ...StoreOption) *MemoryStore

NewMemoryStore returns an empty MemoryStore. WithNow applies; WithSchema is ignored.

func (*MemoryStore) Cancel

func (m *MemoryStore) Cancel(_ context.Context, f Filter, note string) (int, error)

Cancel implements Approvals.

func (*MemoryStore) Decide

func (m *MemoryStore) Decide(ctx context.Context, d Decision, fn FollowUp) (Item, error)

Decide implements Approvals. A follow-up error leaves the item exactly as it was.

func (*MemoryStore) ExpireDue

func (m *MemoryStore) ExpireDue(_ context.Context, scope string) ([]Item, error)

ExpireDue implements Approvals.

func (*MemoryStore) Get

func (m *MemoryStore) Get(_ context.Context, id string) (Item, error)

Get implements Approvals.

func (*MemoryStore) List

func (m *MemoryStore) List(_ context.Context, f Filter) (Page, error)

List implements Approvals. Paging is by offset-free token over creation order, newest first.

func (*MemoryStore) Propose

func (m *MemoryStore) Propose(_ context.Context, it Item) (Item, error)

Propose implements Approvals.

func (*MemoryStore) Redrive

func (m *MemoryStore) Redrive(ctx context.Context, f Filter, fn FollowUp) ([]Item, error)

Redrive implements Approvals.

type Page

type Page struct {
	Items         []Item
	NextPageToken string
}

Page is one page of items, newest first.

type Rejection

type Rejection struct{ Reason string }

Rejection is the error a FollowUp returns to say "approved, but I refuse it": the item becomes rejected and Reason becomes its Outcome.

func (*Rejection) Error

func (r *Rejection) Error() string

type State

type State string

State is where an Item is in its life. Pending is the only state that changes: every other state is final, except that an approved item whose hand-off has not completed (Item.HandedOff false) is re-run by Redrive.

const (
	StatePending   State = "pending"
	StateApproved  State = "approved"
	StateRejected  State = "rejected"
	StateExpired   State = "expired"
	StateCancelled State = "cancelled"
)

type Store

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

Store is the Postgres Approvals over the approvals table (SchemaSQL).

func NewStore

func NewStore(db DB, opts ...StoreOption) *Store

NewStore returns a Store over db. Pass the project's *orm.Client: Decide and Redrive open their transaction through it, and the follow-up's own writes join that transaction when they go through the same client with the ctx the follow-up receives.

func (*Store) Cancel

func (s *Store) Cancel(ctx context.Context, f Filter, note string) (int, error)

Cancel withdraws the pending items matching f (an account retired, a draft deleted) and returns how many it withdrew. note is recorded on each. f must name at least one of IDs, Scope, Kind, SubjectRef or RunID; States is ignored, because only a pending item can be withdrawn.

func (*Store) Decide

func (s *Store) Decide(ctx context.Context, d Decision, fn FollowUp) (Item, error)

Decide records a person's verdict and, for an approval, runs fn.

  • An item past its deadline is expired first (DecidedBy and Note record who tried) and returned in state expired, with a nil error; fn does not run. The same goes for an item a sweeper already expired.
  • Otherwise the item moves from pending to approved or rejected with one conditional UPDATE. Of two concurrent deciders exactly one wins; the other gets a FailedPrecondition error. Deciding an item that is already decided or cancelled is a FailedPrecondition error too, and an unknown id is NotFound.
  • For an approval, fn then runs in the same transaction (see FollowUp). Its error rolls everything back, leaving the item pending; a *Rejection commits the item as rejected; HandoffIncomplete commits it approved and not handed off. A nil fn means there is nothing to hand off.
  • A rejection runs no follow-up: a person saying no must never be blocked by the app's hook.

Decide does not check who is calling. The rpc that calls it is declared human_only and passes By.

func (*Store) ExpireDue

func (s *Store) ExpireDue(ctx context.Context, scope string) ([]Item, error)

ExpireDue moves every pending item whose deadline has passed (ExpiresAt <= now) to expired, in one UPDATE ... RETURNING, and returns them. An empty scope covers every scope. Call it lazily on read paths, or from a sweeper (see the package doc); both are safe at any frequency.

func (*Store) Get

func (s *Store) Get(ctx context.Context, id string) (Item, error)

Get returns one item, or a NotFound error.

func (*Store) List

func (s *Store) List(ctx context.Context, f Filter) (Page, error)

List returns the items matching f, newest first. Paging is keyset on (created_at, id), so a page never repeats or skips a row as items arrive.

func (*Store) Propose

func (s *Store) Propose(ctx context.Context, it Item) (Item, error)

Propose records a pending item and returns it. It is idempotent on (Scope, Kind, SubjectRef): proposing a subject that already has an item, in any state, returns that item unchanged, so a retried agent run proposes once.

func (*Store) Redrive

func (s *Store) Redrive(ctx context.Context, f Filter, fn FollowUp) ([]Item, error)

Redrive runs fn again for approved items whose hand-off never completed (state approved and HandedOff false), oldest first, at most f.PageSize per call, each in its own transaction. Only the Filter's identifying fields (IDs, Scope, Kind, SubjectRef, RunID) apply; States does not.

It returns the items whose follow-up completed, or that a Rejection turned into rejected. An item whose follow-up failed or reported HandoffIncomplete stays as it was, to be tried on the next call; failures are returned joined. Several redrivers may run at once: each item is locked while it is worked, and a redriver that reaches an item another holds waits for it, then re-checks it. So when Redrive returns no error, every item it selected was handed off (by this call or another) or was tried by this call; none is passed over because someone else held it.

type StoreOption

type StoreOption func(*config)

StoreOption configures a Store or a MemoryStore.

func WithNow

func WithNow(now func() time.Time) StoreOption

WithNow sets the clock deadlines and decision times are read from. The default is time.Now; tests pass a fake.

func WithSchema

func WithSchema(schema string) StoreOption

WithSchema names the schema approvals lives in, so every statement says "<schema>".approvals rather than resolving the bare name through the connection's search_path (see audit.WithSchema). Empty leaves it unqualified. A MemoryStore ignores it.

Jump to

Keyboard shortcuts

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