Documentation
¶
Overview ¶
Package approval implements the three-tier mechanism for Class C (irreversible) tools, replacing naive "approve or deny every call" HITL - which trains reviewers to rubber-stamp - with a tiered design where only the irreducible remainder ever reaches a live person. ARCHITECTURE.md has the reasoning; this file is the runtime implementation.
Persistence for the approval Store: envelopes, pending approvals, and holds are the state that must survive a proxy restart mid-pilot (a crash today silently drops every one of them). The audit log stays a plain JSONL file on purpose - append-only is already the right shape for an audit trail; this file is about state that needs to be re-actionable after a restart, not about durability of the history.
Persistence writes are best-effort: a DB hiccup is logged but never blocks or fails the in-memory operation, since audit.log already has the decision recorded and a storage blip shouldn't take down enforcement.
Postgres-backed persistence for multi-instance/HA deployments.
This is a genuinely different concurrency model from persist.go's SQLite-backed NewPersistentStore, not just a different file format. NewPersistentStore treats the in-memory map as the sole authority and SQLite as a write-behind log purely for restart recovery - correct for one process, since Store's own sync.Mutex serializes every access. Once a second process can read/write the same state, that mutex protects nothing: two reviewers hitting two different instances at nearly the same moment could both read "not yet approved" and both flip status to Approved, double-counting a four-eyes approval. So in Postgres mode, Postgres itself is the authority - every mutating method here runs inside a transaction with row-level locking (SELECT ... FOR UPDATE), which serializes concurrent access across ALL instances the same way the in-process mutex serializes access within one. The in-memory maps on Store are allocated but unused in this mode.
Index ¶
- func RequestHash(tool string, args map[string]any, resource, requestedBy string) (string, error)
- type ApprovalStatus
- type Constraint
- type Envelope
- type Hold
- type PendingApproval
- type Store
- func (s *Store) Approve(id, hash, approverUser, approverRole string) (*PendingApproval, error)
- func (s *Store) CancelHold(id, cancellerRole string) (*Hold, error)
- func (s *Store) CheckPreAuth(tool string, args map[string]any) (*Envelope, bool)
- func (s *Store) CreateApproval(tool string, args map[string]any, resource, requestedBy, reviewerRole string, ...) (*PendingApproval, error)
- func (s *Store) CreateEnvelope(tool string, constraints map[string]Constraint, maxUses int, ttl time.Duration) (*Envelope, error)
- func (s *Store) Deny(id, approverUser, approverRole string) error
- func (s *Store) EnqueueHold(tool string, args map[string]any, holdSeconds int, ...) (*Hold, error)
- func (s *Store) Get(id string) (*PendingApproval, bool)
- func (s *Store) GetHold(id string) (*Hold, bool)
- func (s *Store) ListEnvelopes() []*Envelope
- func (s *Store) ListHolds() []*Hold
- func (s *Store) ListPendingApprovals() []*PendingApproval
- func (s *Store) PollAndFireDueHolds(limit int, onFire func(*Hold))
- func (s *Store) ResumableHoldIDs() []string
- func (s *Store) ScheduleResumedHold(id string, onFire func())
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func RequestHash ¶
RequestHash cryptographically binds an approval to the EXACT call - tool, args, resource, and requester. If any of it changes between the ask and the click, the hash no longer matches and the stale approval is rejected.
Types ¶
type ApprovalStatus ¶
type ApprovalStatus string
const ( StatusPending ApprovalStatus = "pending" StatusApproved ApprovalStatus = "approved" StatusDenied ApprovalStatus = "denied" StatusExpired ApprovalStatus = "expired" )
type Constraint ¶
Constraint is a small, explicit matcher DSL - deliberately not a general expression language, so envelopes stay auditable at a glance.
type Hold ¶
type Hold struct {
ID string
Tool string
Args map[string]any
RequestedBy string
DelegatedRole string
AgentID string
FireAt time.Time
Cancelled bool
Fired bool
// RunID lets cancellation optionally trigger compensation of earlier
// steps in the same run - see CancelHold and the REST adapter's
// /holds/{id}/cancel. Empty for an untracked call, same convention as
// PendingApproval.RunID.
RunID string
// ReviewerRole gates who may cancel this hold - the same reviewer_role
// the tool itself requires, checked in CancelHold the same way Deny
// checks it for approvals.
ReviewerRole string
// contains filtered or unexported fields
}
type PendingApproval ¶
type PendingApproval struct {
ID string
Tool string
Args map[string]any
Resource string
RequestedBy string
ReviewerRole string
Hash string
Status ApprovalStatus
CreatedAt time.Time
ExpiresAt time.Time
RunID string // Phase 3: if set, a denial here can trigger internal/saga compensation for prior steps in this run
// Four-eyes support (internal/risk decides RequiredApprovals; this
// package only enforces "N distinct, non-requester approvers", it has
// no opinion on risk scoring itself). Approvers accumulates as each
// qualifying approval lands; Status only flips to StatusApproved once
// len(Approvers) reaches RequiredApprovals.
RequiredApprovals int
Approvers []string
RiskScore float64
TaintedArgs []string
}
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
func NewPersistentStore ¶
NewPersistentStore opens (creating if needed) a SQLite-backed store and reloads any live state from a previous run. Holds are loaded but NOT automatically re-armed here - resuming a hold requires an onFire callback that only the handler package can supply (it needs the registry and the upstream client), so the caller (cmd/rinwall) must call ScheduleResumedHold for each id in ResumableHoldIDs() once the handler exists.
func NewPostgresStore ¶
NewPostgresStore opens (creating the schema if needed) a Postgres-backed Store safe for concurrent use by multiple proxy processes against the same database. Reuses the "pgx" database/sql driver already registered by cmd/rinwall (github.com/jackc/pgx/v5/stdlib) - blank-imported here too so this file has no hidden dependency on being wired up after main.go's own import runs.
func (*Store) Approve ¶
func (s *Store) Approve(id, hash, approverUser, approverRole string) (*PendingApproval, error)
Approve records one qualifying approval, only if: the supplied hash still matches (a payload that changed between the ask and the click is rejected automatically, not silently re-approved against new values), the approver's verified role matches the tool's configured ReviewerRole, the approver is not the original requester - closing the self-approval hole outright rather than relying on role mismatch alone - and the approver hasn't already approved this same request (the trivial way to defeat four-eyes is one person clicking twice). Status only flips to StatusApproved once len(Approvers) reaches RequiredApprovals - the caller must check p.Status before treating this as a green light to execute; a return with Status still Pending means more approvers needed.
func (*Store) CancelHold ¶
CancelHold requires the canceller's role to match the hold's reviewer_role, the same check Deny applies to approvals - cancelling a queued Class C action is at least as sensitive as approving one. Deliberately does NOT block the original requester from cancelling their own hold, unlike self-approval: cancelling only ever prevents an action from happening, it never lets anyone push one through, so the conflict-of-interest self-approval guards against doesn't apply here. Returns the cancelled Hold so a caller can decide whether to also compensate the run it belongs to - see the REST adapter's /holds/{id}/cancel and its compensate_run option.
func (*Store) CheckPreAuth ¶
CheckPreAuth looks for a live envelope covering this exact call and, if found, atomically consumes one use. Tier 1 - most Class C traffic should resolve here, instantly, with no human paged. In Postgres mode, a transient DB error fails closed to "no match" rather than blocking the call - skipping a pre-authorized envelope on a hiccup only costs one extra human approval, never a bypassed check.
func (*Store) CreateApproval ¶
func (s *Store) CreateApproval(tool string, args map[string]any, resource, requestedBy, reviewerRole string, ttl time.Duration, requiredApprovals int, riskScore float64, taintedArgs []string, runID string) (*PendingApproval, error)
CreateApproval opens a Tier 3 (or taint step-up) request, bound to the exact call by hash and routed to whichever reviewer role owns this class of decision. requiredApprovals/riskScore/taintedArgs come from internal/risk's scoring - this package just enforces whatever count it's told, it has no opinion on how that count was decided.
func (*Store) CreateEnvelope ¶
func (*Store) EnqueueHold ¶
func (s *Store) EnqueueHold(tool string, args map[string]any, holdSeconds int, requestedBy, delegatedRole, agentID, runID, reviewerRole string, onFire func()) (*Hold, error)
EnqueueHold queues a Class C action with a natural dispatch delay instead of firing it immediately. onFire runs automatically when the window elapses unless CancelHold is called first - Tier 2. requestedBy/ delegatedRole/agentID are stored purely for audit/resume attribution - they play no role in the fire decision. runID/reviewerRole are stored so CancelHold can enforce who may cancel and, if asked, trigger compensation of the run's earlier steps.
func (*Store) GetHold ¶
GetHold exposes a hold's tool/args/requester so a caller resuming it after a restart (cmd/rinwall) can build the right replay closure - the timer/onFire wiring itself stays inside the store via ScheduleResumedHold. Not used in Postgres mode, where PollAndFireDueHolds replaces both the fresh-fire and restart-resume paths with one mechanism - see postgres.go.
func (*Store) ListEnvelopes ¶
func (*Store) ListPendingApprovals ¶
func (s *Store) ListPendingApprovals() []*PendingApproval
ListPendingApprovals, ListHolds, and ListEnvelopes back the reviewer UI (internal/adapter/rest's GET /approvals, /holds, /envelopes) - point lookups (Get, GetHold) aren't enough for a human to see what's actually waiting on them.
func (*Store) PollAndFireDueHolds ¶
PollAndFireDueHolds claims and fires holds whose window has elapsed, safe to call concurrently from multiple proxy instances polling the same Postgres database: "FOR UPDATE SKIP LOCKED" lets each instance's poll pass skip rows another instance already has locked, so a due hold is claimed by exactly one instance rather than fired twice. onFire is called once per claimed hold, AFTER the row is durably marked fired - the same "mark fired, then run the side effect" order Logger's SQLite path already uses for its timer callback. Call this on a ticker (see cmd/rinwall/main.go) only when Postgres mode is active; SQLite/in-memory mode keeps using time.AfterFunc/ScheduleResumedHold instead, since a single process needs no distributed claiming.
func (*Store) ResumableHoldIDs ¶
ResumableHoldIDs returns holds from a previous run that never fired and were never cancelled - these need a fresh timer via ScheduleResumedHold.
func (*Store) ScheduleResumedHold ¶
ScheduleResumedHold re-arms a hold loaded from a previous run. If its window already elapsed while the process was down, it fires immediately.