approval

package
v0.0.0-...-5b6c5de Latest Latest
Warning

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

Go to latest
Published: May 12, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package approval defines the SPI for the approval orchestrator.

The approval orchestrator manages the human-in-the-loop lifecycle: routing approval requests to the right people, tracking their responses, handling expiry, and issuing execution grants when approval is granted.

The built-in implementation persists approvals in the database and dispatches notifications via the notify.Notifier SPI. Alternative implementations can integrate with external workflow systems.

Index

Constants

View Source
const (
	DecisionAllowOnce    = "allow_once"
	DecisionDeny         = "deny"
	DecisionAllowProject = "allow_project"
	DecisionAllowUser    = "allow_user"
)

Wire decision strings the policy-enforced shell understands. The `aileron-sh` shim and the daemon's shell-approval handler agree on this vocabulary verbatim — the four buttons on the webapp approval card map one-to-one. Any error path (timeout, context cancelled, wrong shape on the wire) collapses to DecisionDeny so the shell fails closed.

View Source
const SavePolicyKey = "save_policy"

SavePolicyKey is the [ActionDecision.EditedPayload] key the webapp sets when a shell approval should also save the rule. Values are `""` (or absent — approve once), `"project"`, or `"user"`. The daemon translates that into the wire string `aileron-sh` expects; any other value degrades to DecisionAllowOnce rather than failing the whole approval.

Variables

View Source
var ErrActionApprovalNotFound = errors.New("action approval not found")

ErrActionApprovalNotFound is returned by ActionApprovalQueue.Decide when the requested approval id is unknown or already resolved.

View Source
var ErrActionApprovalTimeout = errors.New("action approval timed out")

ErrActionApprovalTimeout is returned by ActionApproval.Wait when the user did not decide within the supplied timeout. The handler surfaces this to the agent as a structured `approval_timeout` failure envelope so the agent can recover gracefully.

Functions

func WireDecision

func WireDecision(d ActionDecision, waitErr error) string

WireDecision maps an ActionDecision + ActionApproval.Wait error into the four-option vocabulary `aileron-sh` understands. waitErr non-nil (timeout, ctx cancelled, etc.) and !d.Approved both collapse to DecisionDeny. The "save and approve" verdicts ride through EditedPayload under SavePolicyKey; this function is the single canonical translator so the shell-approval handler and any future CLI consumer agree on the mapping without copy-pasting.

Types

type ActionApproval

type ActionApproval struct {
	// ID is opaque, server-minted, unique within this process. Embedded
	// in the agent-facing tool-call hold and in webapp/CLI list views.
	ID string

	// Kind discriminates the user-facing card layout. Empty values
	// are treated as [ApprovalKindAction] by the webapp and the API
	// layer to keep older callers working without changes.
	Kind ApprovalKind

	// ActionName is the manifest name of the gated action (e.g.
	// "send-email"). For non-action kinds, carries a short label
	// describing what's being approved ("send_message", "draft_reply",
	// "http_request"). Read-only after Register.
	ActionName string

	// ConnectorFQN is the connector the action's first execute step
	// targets — useful in the user surface for "send_email via
	// github://ALRubinger/aileron-connector-google". Read-only.
	ConnectorFQN string

	// Args are the call-time arguments the agent passed in. Surfaced
	// to the user so they can see what would actually be sent. Read-only.
	Args map[string]any

	// SessionID is the launch session that initiated the request,
	// when one is in scope. Empty for daemon-direct callers.
	SessionID string

	// RequestedAt is when the queue minted this request. Read-only.
	RequestedAt time.Time
	// contains filtered or unexported fields
}

ActionApproval is a pending request to gate one action invocation.

Distinct from the rich governance flow modeled by the Orchestrator SPI (intents → multi-approver workflows → execution grants). This is the runtime-blocking shape for "ask the user yes or no before this specific action runs" — single user, single decision, the answer unblocks a held-open action-run HTTP response. Applies to actions whose manifest declares `[approval] required = true` (see action.Manifest.ApprovalRequired).

Convergence with the rich Orchestrator surface is post-MVP and belongs in #418's webapp work; for v0.x the simpler queue keeps the runtime-block path easy to reason about.

func (*ActionApproval) Wait

func (a *ActionApproval) Wait(ctx context.Context, timeout time.Duration) (ActionDecision, error)

Wait blocks until the user decides, the timeout fires, or ctx is done. Returns the user's decision on resolution; an error wrapping ErrActionApprovalTimeout on timeout; ctx.Err() if the caller's context cancels first.

The runtime calls this from its action-run handler while the HTTP client (the agent's MCP server, ultimately) waits on the held-open response. When this returns, the handler proceeds to either execute the action (Approved) or write an `approval_denied` failure envelope to the response.

Emits an `aileron.approval.wait` span covering the blocked-on-user interval. Span attribute keys mirror the audit-event keys emitted by Register / Decide so consumers can correlate the queue's three audit events with this span by `aileron.approval.id`. The span's `aileron.approval.wait_ms` is computed identically to the approved/denied audit event.

type ActionApprovalEvent

type ActionApprovalEvent struct {
	Type     ActionApprovalEventType
	Pending  *ActionApproval
	Resolved *ResolvedActionApproval
}

ActionApprovalEvent is the shape carried on the channel returned by ActionApprovalQueue.Subscribe. Exactly one of Pending / Resolved is populated per the Type discriminant.

type ActionApprovalEventType

type ActionApprovalEventType string

ActionApprovalEventType discriminates the union of events the queue publishes to subscribers. The webapp consumes these via SSE (#418) so the pending list updates live without polling.

const (
	// ActionApprovalEventPending fires when [ActionApprovalQueue.Register]
	// adds a new pending entry. Event.Pending is the entry; Event.Resolved
	// is nil.
	ActionApprovalEventPending ActionApprovalEventType = "pending"

	// ActionApprovalEventResolved fires when [ActionApprovalQueue.Decide]
	// resolves an entry. Event.Resolved carries the user's verdict;
	// Event.Pending is nil.
	ActionApprovalEventResolved ActionApprovalEventType = "resolved"
)

type ActionApprovalQueue

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

ActionApprovalQueue is the in-memory store of action-level approval entries. Entries are retained through their full lifecycle — pending_approval → running → completed/denied/failed — so the agent can poll `/v1/action-approvals/{id}/result` for the outcome of an approval that was registered out from under it. v0.x is per-process; restarts drop every entry. Persistent backing parallels the audit- log persistence work in #412 — same architectural decision deferred for the same reasons.

All methods are safe for concurrent use.

func NewActionApprovalQueue

func NewActionApprovalQueue(idGen func() string, now func() time.Time) *ActionApprovalQueue

NewActionApprovalQueue returns an empty queue using the supplied id generator and clock. Pass nil for either to use the package defaults — useful in production wiring.

func (*ActionApprovalQueue) Decide

func (q *ActionApprovalQueue) Decide(id string, approved bool, reason string, editedPayload map[string]any) error

Decide resolves a pending approval with the user's verdict. The entry is retained — the queue keeps it through its full lifecycle so the agent can poll `/v1/action-approvals/{id}/result` for the outcome. A second Decide call on the same id returns ErrActionApprovalNotFound (already resolved or unknown).

On approve, the outcome flips to OutcomeRunning; the background executor that registered to listen on the entry's decision channel is expected to call [SetRunning]/[SetCompleted]/[SetFailed] as it progresses. On deny, the outcome flips to OutcomeDenied and the executor's goroutine is expected to exit cleanly without invoking the action.

editedPayload carries kind-specific fields the user changed before approving — e.g. the edited reply body for ApprovalKindCommsDraft (`{ "body": "...new..." }`). Pass nil when there are no edits.

func (*ActionApprovalQueue) Get

Get returns the pending approval for id, or nil + false when the id is unknown or already resolved.

func (*ActionApprovalQueue) List

func (q *ActionApprovalQueue) List() []*ActionApproval

List returns the snapshot of currently pending approvals, ordered by RequestedAt ascending (oldest first). The slice is freshly allocated; the caller may mutate it without affecting the queue.

Entries whose outcome has progressed past OutcomePendingApproval (running, completed, denied, failed) are excluded — the webapp's pending list and the SSE snapshot want only the actionable subset. The agent retrieves terminal outcomes via Outcome.

func (*ActionApprovalQueue) Outcome

func (q *ActionApprovalQueue) Outcome(id string) (Outcome, bool)

Outcome returns the current lifecycle state for an entry by id. Returns false for unknown ids (including ids minted in a previous process — the queue is in-memory). The returned Outcome is a copy; callers can safely retain it.

func (*ActionApprovalQueue) Register

func (q *ActionApprovalQueue) Register(actionName, connectorFQN, sessionID string, args map[string]any) *ActionApproval

Register creates and tracks a new pending approval. The returned pointer is the caller's handle for ActionApproval.Wait. When an onRegister callback has been installed via SetOnRegister, it is invoked synchronously after the entry is in the map and before Register returns. Callback errors / panics are not propagated.

Kind defaults to ApprovalKindAction. Use [RegisterKind] (or one of the typed wrappers like [RegisterCommsSend]) when the queue entry should render as something other than the default action card.

func (*ActionApprovalQueue) RegisterCommsDraft

func (q *ActionApprovalQueue) RegisterCommsDraft(service, channel, originalAuthor, originalBody, draftBody, replyTo, sessionID string) *ActionApproval

RegisterCommsDraft creates a ApprovalKindCommsDraft entry (#428). The card surfaces the original incoming message alongside the draft body in an editable field; the user can approve as-is, edit then approve (the queue ships the edited body to the dispatcher via [ActionDecision.EditedPayload]), or discard.

func (*ActionApprovalQueue) RegisterCommsSend

func (q *ActionApprovalQueue) RegisterCommsSend(service, channel, body, sessionID string) *ActionApproval

RegisterCommsSend creates a ApprovalKindCommsSend entry (#428). The webapp renders a card with service / channel / body and an approve / deny pair; on approve, the CommsServer dispatches the message via the matched [comms.Listener].

func (*ActionApprovalQueue) RegisterHTTPRequest

func (q *ActionApprovalQueue) RegisterHTTPRequest(method, url, body, secretName, sessionID string) *ActionApproval

RegisterHTTPRequest creates a ApprovalKindHTTPRequest entry (#428). secretName is the name of the matched api_key binding the daemon would inject as a Bearer token, surfaced so the user can decide whether to authorise the call. The card never carries the secret value — only the binding name.

func (*ActionApprovalQueue) RegisterKind

func (q *ActionApprovalQueue) RegisterKind(kind ApprovalKind, actionName, connectorFQN, sessionID string, args map[string]any) *ActionApproval

RegisterKind is the kind-aware Register. Production callers use the typed wrappers below; tests and callers in feature packages take this directly when they want full control of the queue entry's fields.

func (*ActionApprovalQueue) SetAuditRecorder

func (q *ActionApprovalQueue) SetAuditRecorder(rec audit.Recorder)

SetAuditRecorder installs the audit.Recorder the queue calls into on Register / Decide. Pass nil to disable emission. Safe to call concurrently with the queue's other methods; the swap is mutex- protected.

Production wiring sets this once at daemon startup to the same recorder the connector-install / binding-lifecycle paths use, so the approval flow appears in `~/.aileron/audit.jsonl` alongside every other significant event.

func (*ActionApprovalQueue) SetCompleted

func (q *ActionApprovalQueue) SetCompleted(id, auditID, result string) error

SetCompleted records a successful action execution against an approved entry. Called by the background executor after the action's runtime returns a non-failure result. Flips the outcome to OutcomeCompleted and stores the audit id + result payload for later retrieval by `/v1/action-approvals/{id}/result`.

Idempotent for the terminal-state contract: calling on an already- terminal entry is a no-op (the queue's outcome is single-shot per entry). Returns ErrActionApprovalNotFound for unknown ids.

func (*ActionApprovalQueue) SetFailed

func (q *ActionApprovalQueue) SetFailed(id string, fail *failure.Failure, errorMessage string) error

SetFailed records an execution failure against an approved entry. The background executor calls this when [action.Executor.Execute] returns an error (errorMessage non-empty, failure nil) or when the executor returns a structured FailureEnvelope (failure non-nil). Exactly one of failure / errorMessage should be populated; both nil/empty produces a generic "execution failed" entry to keep the invariant non-nil for /result consumers.

Idempotent for the terminal-state contract: a no-op on already- terminal entries. Returns ErrActionApprovalNotFound for unknown ids.

func (*ActionApprovalQueue) SetOnRegister

func (q *ActionApprovalQueue) SetOnRegister(fn func(*ActionApproval))

SetOnRegister installs a callback fired synchronously after each successful Register. Pass nil to clear. Safe to call concurrently with Register/List/Decide/Get; the swap is mutex-protected.

Production wiring uses this to fire a desktop notification + log line on each new pending approval so the user knows to look at the webapp. Test code uses it to record call sequences.

func (*ActionApprovalQueue) Subscribe

func (q *ActionApprovalQueue) Subscribe() (<-chan ActionApprovalEvent, func())

Subscribe registers a streaming consumer of queue events and returns the receive channel plus a cancel function the caller MUST invoke on disconnect. The channel is buffered; if a subscriber stops draining it, broadcast drops events for that subscriber rather than blocking Register / Decide. Slow consumers can resync from [List] when they reconnect.

Used by the SSE handler (`GET /v1/action-approvals/watch`, #418) so the webapp's pending list updates live without polling. Each open SSE connection holds one subscription for the duration of the HTTP request.

type ActionDecision

type ActionDecision struct {
	// Approved is true when the user permits the action to run.
	Approved bool

	// Reason is optional commentary the user attached to the deny
	// path (e.g. "wrong recipient"). Empty for approve.
	Reason string

	// EditedPayload carries kind-specific fields the user changed
	// before approving — most prominently the edited reply body for
	// [ApprovalKindCommsDraft] (`{ "body": "...new..." }`). Nil when
	// the user approved without edits, or when the kind doesn't
	// support editing. Surfaced to the dispatcher (e.g. CommsServer)
	// so the user-edited bytes are what actually go on the wire.
	EditedPayload map[string]any

	// DecidedAt is when Decide was called.
	DecidedAt time.Time
}

ActionDecision is the user's verdict on an ActionApproval.

type ActorStatus

type ActorStatus string

ActorStatus is an individual approver's response.

const (
	ActorStatusPending   ActorStatus = "pending"
	ActorStatusApproved  ActorStatus = "approved"
	ActorStatusDenied    ActorStatus = "denied"
	ActorStatusDelegated ActorStatus = "delegated"
)

type Approval

type Approval struct {
	ApprovalID     string
	IntentID       string
	WorkspaceID    string
	Status         Status
	Rationale      string
	Approvers      []ApproverActor
	EditableBounds map[string]any
	ExpiresAt      *time.Time
	RequestedAt    time.Time
	ResolvedAt     *time.Time
}

Approval is the full approval record.

type ApprovalKind

type ApprovalKind string

ApprovalKind discriminates the user-facing card layout the webapp renders for a queue entry. Defaults to ApprovalKindAction for historic action-manifest gated calls; the comms-MCP send-shaped tools (#428) and the shell-shim approval surface (#427) register entries with their own kinds so the webapp can surface kind- specific fields (editable reply body, command + reason, method + URL + matched secret name).

New kinds extend the enum; the default-when-empty fallback to ApprovalKindAction keeps older Register call sites compiling without forcing every caller to spell the kind out.

const (
	// ApprovalKindAction is the historic "an action declares
	// `[approval] required = true`" gate. Card layout: action name,
	// optional connector FQN, args dump, approve / deny.
	ApprovalKindAction ApprovalKind = "action"

	// ApprovalKindCommsSend is `aileron-mcp`'s `send_message` tool
	// (#428). Card layout: service + channel + message body, approve
	// / deny.
	ApprovalKindCommsSend ApprovalKind = "comms_send"

	// ApprovalKindCommsDraft is `aileron-mcp`'s `draft_reply` tool
	// (#428). Card layout: original message + draft reply (editable),
	// approve / approve-with-edits / discard.
	ApprovalKindCommsDraft ApprovalKind = "comms_draft"

	// ApprovalKindHTTPRequest is `aileron-mcp`'s `http_request` tool
	// (#428). Card layout: method + URL + body + which credential
	// would be injected (name only, never the value), approve / deny.
	ApprovalKindHTTPRequest ApprovalKind = "http_request"
)

type ApprovalRequest

type ApprovalRequest struct {
	IntentID    string
	WorkspaceID string
	// Rationale is the human-readable reason approval is required.
	Rationale string
	// Approvers is the list of principals who may act on this request.
	Approvers []ApproverRef
	ExpiresAt *time.Time
	// EditableBounds describes which parameters an approver may modify.
	EditableBounds map[string]any
}

ApprovalRequest is the input for creating a new approval.

type ApproveRequest

type ApproveRequest struct {
	Comment             string
	ApproveOnce         bool
	StepUpAuthAssertion string
}

ApproveRequest is the input for an approval decision.

type ApproverActor

type ApproverActor struct {
	PrincipalID string
	DisplayName string
	Role        string
	Status      ActorStatus
}

ApproverActor is an approver with their individual status.

type ApproverRef

type ApproverRef struct {
	PrincipalID string
	DisplayName string
	Role        string
}

ApproverRef identifies a principal who may approve or deny.

type DenyRequest

type DenyRequest struct {
	Reason  string
	Comment string
}

DenyRequest is the input for a denial decision.

type InMemoryOrchestrator

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

InMemoryOrchestrator implements Orchestrator using in-memory stores.

func NewInMemoryOrchestrator

func NewInMemoryOrchestrator(approvals store.ApprovalStore, idGen func() string) *InMemoryOrchestrator

NewInMemoryOrchestrator returns an orchestrator backed by the given store. The idGen function should return unique IDs (e.g., UUIDs).

func (*InMemoryOrchestrator) Approve

func (o *InMemoryOrchestrator) Approve(ctx context.Context, approvalID string, req ApproveRequest) (Approval, error)

func (*InMemoryOrchestrator) Deny

func (o *InMemoryOrchestrator) Deny(ctx context.Context, approvalID string, req DenyRequest) (Approval, error)

func (*InMemoryOrchestrator) Get

func (o *InMemoryOrchestrator) Get(ctx context.Context, approvalID string) (Approval, error)

func (*InMemoryOrchestrator) List

func (o *InMemoryOrchestrator) List(ctx context.Context, filter ListFilter) ([]Approval, error)

func (*InMemoryOrchestrator) Modify

func (o *InMemoryOrchestrator) Modify(ctx context.Context, approvalID string, req ModifyRequest) (Approval, error)

func (*InMemoryOrchestrator) Request

type ListFilter

type ListFilter struct {
	WorkspaceID string
	Assignee    string
	Status      Status
	PageSize    int
	PageToken   string
}

ListFilter scopes an approval list query.

type ModifyRequest

type ModifyRequest struct {
	Comment       string
	Modifications map[string]any
}

ModifyRequest approves with bounded parameter constraints.

type Orchestrator

type Orchestrator interface {
	// Request creates a new approval request for the given intent.
	Request(ctx context.Context, req ApprovalRequest) (Approval, error)

	// Get returns an approval by ID.
	Get(ctx context.Context, approvalID string) (Approval, error)

	// Approve records an approval decision and issues an execution grant.
	Approve(ctx context.Context, approvalID string, req ApproveRequest) (Approval, error)

	// Deny records a denial and closes the intent.
	Deny(ctx context.Context, approvalID string, req DenyRequest) (Approval, error)

	// Modify approves with parameter constraints, bounding what the agent
	// may execute.
	Modify(ctx context.Context, approvalID string, req ModifyRequest) (Approval, error)

	// List returns pending approvals, optionally filtered by assignee.
	List(ctx context.Context, filter ListFilter) ([]Approval, error)
}

Orchestrator manages the lifecycle of approval requests.

type Outcome

type Outcome struct {
	Status       OutcomeStatus
	AuditID      string
	Result       string
	DenyReason   string
	Failure      *failure.Failure
	ErrorMessage string
}

Outcome is the terminal-or-transient state of an action-approval entry. The zero value is invalid; use ActionApprovalQueue.Outcome to read it. Fields beyond Status are populated according to Status:

  • OutcomePendingApproval, OutcomeRunning — no other fields.
  • OutcomeCompleted — AuditID, Result.
  • OutcomeDenied — DenyReason (may be empty).
  • OutcomeFailed — exactly one of Failure or ErrorMessage.

Stored on the queue rather than on ActionApproval directly so the queue's mutex guards every read and write — callers can't race with background executors recording results.

type OutcomeStatus

type OutcomeStatus string

OutcomeStatus is the closed lifecycle of an action-approval entry from registration through terminal state. Reported by the queue to the `/v1/action-approvals/{id}/result` handler and surfaced to the agent via the `check_action_status` MCP tool.

The five states cover what the agent / user cares about; intermediate internal transitions (e.g. "decided but executor not yet started") are not modeled separately because the user-visible answer is the same either way.

const (
	// OutcomePendingApproval — the entry is registered; the user has
	// not yet decided.
	OutcomePendingApproval OutcomeStatus = "pending_approval"

	// OutcomeRunning — the user approved; the daemon's background
	// executor is running the action. Transient.
	OutcomeRunning OutcomeStatus = "running"

	// OutcomeCompleted — the action ran successfully. [Outcome.AuditID]
	// and [Outcome.Result] are populated.
	OutcomeCompleted OutcomeStatus = "completed"

	// OutcomeDenied — the user denied the approval. [Outcome.DenyReason]
	// carries any commentary the user attached.
	OutcomeDenied OutcomeStatus = "denied"

	// OutcomeFailed — the action was approved but its execution errored
	// or returned an ADR-0010 failure envelope. [Outcome.Failure]
	// carries the structured failure when one is available; for
	// executor-level errors (no envelope) [Outcome.ErrorMessage]
	// carries the plain-text reason.
	OutcomeFailed OutcomeStatus = "failed"
)

type ResolvedActionApproval

type ResolvedActionApproval struct {
	ID        string
	Kind      ApprovalKind
	Approved  bool
	Reason    string
	DecidedAt time.Time
}

ResolvedActionApproval describes a queue entry that has been decided. Carried on ActionApprovalEvent so the webapp can drop the matching card without re-fetching the list.

type Status

type Status string

Status is the overall state of an approval request.

const (
	StatusPending   Status = "pending"
	StatusApproved  Status = "approved"
	StatusDenied    Status = "denied"
	StatusModified  Status = "modified"
	StatusDelegated Status = "delegated"
	StatusExpired   Status = "expired"
	StatusCancelled Status = "cancelled"
)

Jump to

Keyboard shortcuts

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