Public facade for the governed-tool proposal system (D-30).
Public API
validate/3— pure, re-callable, orderedwithpipeline returning a fail-closed outcome for every governance gate (TOOL-03, D-15, D-17).propose/3— thin persistence wrapper: validates, derives idempotency key, co-commitsToolProposal+ToolActionEventsynchronously, handles duplicates (TOOL-04, D-26, D-25).request_approval/2— opens a:pendingToolApprovallane for a:requires_approvalproposal; setsexpires_at = now + ttl(host-configurable; default172_800seconds / 48 hours, D15-13); emits:approval_requestedevent; schedulesApprovalExpiryWorkerpost-transaction via injectableenqueue_fn(Pattern 4).approve/3— persists:approveddecision + event, then enqueuesApprovalResumeWorkervia injectableenqueue_fn; NEVER callsrun/3(APRV-01). Guarded on current status== :pending.reject/3— persists:rejecteddecision + event; requires a:reason(FLOW-03). Guarded on:pending. No enqueue.defer/3— persists:deferreddecision + event; requires a:reason(FLOW-03). Guarded on:pending. No enqueue.expire/2— persists:expireddecision + event (admin/facade parity). Guarded on:pending.get_proposal/1— read helper.list_events/1— read helper.
Validation Pipeline (validate/3)
Clause order IS the precedence (D-17). Never reorder:
gate 0 (resolve_tool) → {:blocked, :unsupported, :unknown_tool} — pre-persistence
gate 1 (validate_input) → {:blocked, :needs_input, changeset}
gate 2 (check_scope) → {:blocked, :scope_invalid, reason}
gate 3 (authorize) → {:blocked, :policy_denied, reason}
success → {:ok, validated_attrs}Persistence (propose/3)
- Unknown tool (
:unsupported): telemetry only, NO row inserted (D-18, Pitfall 7). - Known tool blocked by scope/policy: proposal persisted with blocked status + reason,
plus a
:proposal_blockedevent (D-18 Support-Truth Gate). - Happy path: proposal +
:proposal_createdevent co-committed in onewith(D-26). - Duplicate idempotency key: returns existing proposal, no second insert (D-25).
No Execution (from propose/approve)
propose/3 never calls run/3. approve/3 persists the decision + enqueues the resume
worker asynchronously — it never executes inline (APRV-01, D15-10). Execution is performed
by ToolExecutionWorker (the ONLY place run/3 is called), which is enqueued by the resume
worker after transitioning to :execution_pending. execute_approved/2 is the facade-level
API for that enqueue step.
Approval TTL
The default TTL for approval lanes is 172_800 seconds (48 hours). Override per-call
via ttl_seconds: opt, or set globally via Application.put_env(:cairnloop, :approval_ttl_seconds, N).
Summary
Functions
Approves a :pending governed tool approval.
Defers a :pending governed tool approval.
Transitions a :execution_pending governed tool approval to signal that execution has
been enqueued. Enqueues ToolExecutionWorker after the record is persisted
(record-before-enqueue ordering mirrors approve/3, APRV-01).
Expires a :pending governed tool approval (admin/facade parity with ApprovalExpiryWorker).
Returns the single :pending ToolApproval for a given tool_proposal_id, or nil.
Returns the most-recent ToolApproval for a given tool_proposal_id, or nil.
Returns a ToolProposal by id, or nil if not found.
Returns all ToolActionEvent records for a given proposal id, ordered by inserted_at.
Returns all ToolProposal records for a given conversation_id, ordered newest-first,
with their events preloaded in ascending inserted_at order.
Synchronously propose a governed tool call, persisting proposal + event (D-26).
Rejects a :pending governed tool approval.
Opens a :pending ToolApproval lane for a :requires_approval proposal.
Pure, re-callable validation pipeline. No DB interaction, no side effects (D-15).
Functions
Approves a :pending governed tool approval.
Persists status :approved + :approved event, THEN enqueues ApprovalResumeWorker
via injectable enqueue_fn (default: &safe_enqueue/1). Record written BEFORE enqueue
(APRV-01). NEVER calls run/3 — execution is async via the resume worker (D15-10).
Returns {:ok, %ToolApproval{}} on success.
Returns {:error, :not_found} if the approval does not exist.
Returns {:error, :not_pending} if the approval is not in :pending status (T-force-resolved).
Options
:note— optional operator note (stored as reason, D15-07):enqueue_fn— injectable enqueue callback for testing (default:&safe_enqueue/1)
Defers a :pending governed tool approval.
Requires a :reason (FLOW-03) — without one, returns {:error, changeset} and
persists nothing. Persists status :deferred + :deferred event. No enqueue.
Returns {:ok, %ToolApproval{}} on success.
Returns {:error, :not_found} or {:error, :not_pending} for status guards.
Returns {:error, changeset} if reason is missing (FLOW-03).
Options
:reason— REQUIRED operator-visible reason (FLOW-03)
Transitions a :execution_pending governed tool approval to signal that execution has
been enqueued. Enqueues ToolExecutionWorker after the record is persisted
(record-before-enqueue ordering mirrors approve/3, APRV-01).
This is a facade-level API primarily for caller ergonomics; the resume worker calls
safe_enqueue(ToolExecutionWorker.new(...)) directly. This API is useful when
callers need the injectable enqueue_fn for testing.
Returns {:ok, approval} on success.
Returns {:error, :not_found} if the approval does not exist.
Returns {:error, :not_execution_pending} if the approval is not in :execution_pending status.
Options
:enqueue_fn— injectable enqueue callback for testing (default:&safe_enqueue/1)
Expires a :pending governed tool approval (admin/facade parity with ApprovalExpiryWorker).
Persists status :expired + :expired event. No enqueue. Guarded on :pending.
Returns {:ok, %ToolApproval{}} on success.
Returns {:error, :not_found} or {:error, :not_pending} for status guards.
Options
:actor_id— actor performing the expiry (default:"system")
Returns the single :pending ToolApproval for a given tool_proposal_id, or nil.
The one-active-lane partial unique index (APRV-04) guarantees at most one :pending
approval record exists per proposal. All reads go through the narrow Cairnloop.Governance
facade — pipeline internals stay private (D15-17).
Returns the most-recent ToolApproval for a given tool_proposal_id, or nil.
Status-agnostic: returns whichever approval record was updated most recently,
regardless of status (:pending, :executed, :execution_failed, etc.).
Use this for display/outlook resolution so terminal lanes (:executed,
:execution_failed) are visible even when the approval is not preloaded and
get_active_approval/1 (which filters to :pending) would return nil.
Leave get_active_approval/1 in place for the footer affordance and other
callers that specifically need the active pending lane (D15-17, CR-02 fix).
Returns a ToolProposal by id, or nil if not found.
Returns all ToolActionEvent records for a given proposal id, ordered by inserted_at.
Returns all ToolProposal records for a given conversation_id, ordered newest-first,
with their events preloaded in ascending inserted_at order.
Returns [] for an unknown or NULL conversation_id.
Used by Wave 2 to populate the governed-action rail in the conversation LiveView.
Goes through the repo() indirection — never Cairnloop.Repo directly (D-30).
Synchronously propose a governed tool call, persisting proposal + event (D-26).
Calls validate/3 then:
{:blocked, :unsupported, _}: telemetry only — NO row inserted (D-18, Pitfall 7).{:blocked, outcome, reason}for a resolved tool: persists proposal with blocked status + a:proposal_blockedevent (D-18 Support-Truth Gate).{:ok, validated}: derives idempotency key, co-commits proposal +:proposal_createdevent; on duplicate unique constraint, returns the existing proposal (D-25).
Does NOT call run/3. Does NOT enqueue Oban (D-26).
Rejects a :pending governed tool approval.
Requires a :reason (FLOW-03) — without one, returns {:error, changeset} and
persists nothing. Persists status :rejected + :rejected event. No enqueue.
Returns {:ok, %ToolApproval{}} on success.
Returns {:error, :not_found} or {:error, :not_pending} for status guards.
Returns {:error, changeset} if reason is missing (FLOW-03).
Options
:reason— REQUIRED operator-visible reason (FLOW-03)
Opens a :pending ToolApproval lane for a :requires_approval proposal.
Sets expires_at = now + ttl_seconds (default 172_800 s / 48 h — D15-13).
Co-commits the approval record + an :approval_requested ToolActionEvent.
AFTER the transaction, schedules ApprovalExpiryWorker via enqueue_fn
(Pattern 4 — post-transaction, NOT inside the with).
Returns {:ok, %ToolApproval{}} on success, or {:error, changeset} if the
one-active-lane unique constraint fires (APRV-04 — concurrent request for the same
proposal).
Only opens a lane for :requires_approval proposals; :auto/:always_block
proposals are rejected fail-closed with {:error, :not_requires_approval} and
no lane is opened (D15-05).
Options
:ttl_seconds— override TTL for this lane (default:approval_ttl_seconds/0):actor_id— actor opening the lane (defaults toproposal.actor_id):enqueue_fn— injectable enqueue callback for testing (default:&safe_enqueue/1)
Pure, re-callable validation pipeline. No DB interaction, no side effects (D-15).
Returns one of:
{:ok, validated_attrs}— all gates pass; attrs include resolved risk_tier, approval_mode, and three snapshot maps.{:blocked, :unsupported, :unknown_tool}— tool_ref not in registry.{:blocked, :needs_input, changeset}— typed input invalid.{:blocked, :scope_invalid, reason}— actor scope unmet.{:blocked, :policy_denied, reason}— authorize/2 denied.
Clause ORDER is the precedence (D-17). Never reorder.