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
- Variables
- func WireDecision(d ActionDecision, waitErr error) string
- type ActionApproval
- type ActionApprovalEvent
- type ActionApprovalEventType
- type ActionApprovalQueue
- func (q *ActionApprovalQueue) Decide(id string, approved bool, reason string, editedPayload map[string]any) error
- func (q *ActionApprovalQueue) Get(id string) (*ActionApproval, bool)
- func (q *ActionApprovalQueue) List() []*ActionApproval
- func (q *ActionApprovalQueue) Outcome(id string) (Outcome, bool)
- func (q *ActionApprovalQueue) Register(actionName, connectorFQN, sessionID string, args map[string]any) *ActionApproval
- func (q *ActionApprovalQueue) RegisterCommsDraft(...) *ActionApproval
- func (q *ActionApprovalQueue) RegisterCommsSend(service, channel, body, sessionID string) *ActionApproval
- func (q *ActionApprovalQueue) RegisterHTTPRequest(method, url, body, secretName, sessionID string) *ActionApproval
- func (q *ActionApprovalQueue) RegisterKind(kind ApprovalKind, actionName, connectorFQN, sessionID string, ...) *ActionApproval
- func (q *ActionApprovalQueue) SetAuditRecorder(rec audit.Recorder)
- func (q *ActionApprovalQueue) SetCompleted(id, auditID, result string) error
- func (q *ActionApprovalQueue) SetFailed(id string, fail *failure.Failure, errorMessage string) error
- func (q *ActionApprovalQueue) SetOnRegister(fn func(*ActionApproval))
- func (q *ActionApprovalQueue) Subscribe() (<-chan ActionApprovalEvent, func())
- type ActionDecision
- type ActorStatus
- type Approval
- type ApprovalKind
- type ApprovalRequest
- type ApproveRequest
- type ApproverActor
- type ApproverRef
- type DenyRequest
- type InMemoryOrchestrator
- func (o *InMemoryOrchestrator) Approve(ctx context.Context, approvalID string, req ApproveRequest) (Approval, error)
- func (o *InMemoryOrchestrator) Deny(ctx context.Context, approvalID string, req DenyRequest) (Approval, error)
- func (o *InMemoryOrchestrator) Get(ctx context.Context, approvalID string) (Approval, error)
- func (o *InMemoryOrchestrator) List(ctx context.Context, filter ListFilter) ([]Approval, error)
- func (o *InMemoryOrchestrator) Modify(ctx context.Context, approvalID string, req ModifyRequest) (Approval, error)
- func (o *InMemoryOrchestrator) Request(ctx context.Context, req ApprovalRequest) (Approval, error)
- type ListFilter
- type ModifyRequest
- type Orchestrator
- type Outcome
- type OutcomeStatus
- type ResolvedActionApproval
- type Status
Constants ¶
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.
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 ¶
var ErrActionApprovalNotFound = errors.New("action approval not found")
ErrActionApprovalNotFound is returned by ActionApprovalQueue.Decide when the requested approval id is unknown or already resolved.
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 ¶
func (q *ActionApprovalQueue) Get(id string) (*ActionApproval, bool)
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 ¶
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 ¶
ApproverRef identifies a principal who may approve or deny.
type DenyRequest ¶
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) 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 ¶
func (o *InMemoryOrchestrator) Request(ctx context.Context, req ApprovalRequest) (Approval, error)
type ListFilter ¶
type ListFilter struct {
WorkspaceID string
Assignee string
Status Status
PageSize int
PageToken string
}
ListFilter scopes an approval list query.
type ModifyRequest ¶
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.