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 a single Cairnloop.Outbound.BulkEnvelope by id, or nil if not found.
Returns the most-recent ToolApproval for a given tool_proposal_id, or nil.
Returns a ToolProposal by id, or nil if not found.
Returns ToolActionEvent records across all proposals as a newest-first timeline,
with their tool_proposal preloaded for display context.
Returns the subset of candidate_ids whose conversations are currently eligible
to be targets of a bulk recovery follow-up.
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 recent Cairnloop.Outbound.BulkEnvelope rows ordered requested_at desc.
Returns a cohort preview map for the bulk-recovery confirmation modal (D-07).
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 a single Cairnloop.Outbound.BulkEnvelope by id, or nil if not found.
The id is a binary UUID string (e.g. from Ecto.UUID.generate/0) — the
BulkEnvelope PK is :binary_id with autogenerate: false, so callers
supply the UUID at bulk-trigger confirmation time and use the same value
here to fetch the row.
Returns nil on miss (does NOT raise) per D-06 — callers branch on the
result. Goes through repo().get/2; never the concrete repo module
directly (D-14).
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 ToolActionEvent records across all proposals as a newest-first timeline,
with their tool_proposal preloaded for display context.
This is the facade read that backs the operator audit log (AUDIT-01). The web layer
MUST go through this function rather than querying ToolActionEvent directly (D-30).
Options:
:limit— cap the number of rows returned (default100). Use for pagination.:offset— skip this many rows (default0).
Goes through the repo() indirection — never Cairnloop.Repo directly (D-30).
Returns the subset of candidate_ids whose conversations are currently eligible
to be targets of a bulk recovery follow-up.
v1 eligibility is status == :resolved (D-01). The caller MUST pre-filter
candidate_ids to the currently-visible cohort (D-02 forbids "select across all
pages"); this function does NOT scan the full table.
Order of the returned ids is not guaranteed (the caller cares about set
membership, not ordering — see preview_bulk_recovery_cohort/1 for the
display-ordered variant).
Reads through the narrow facade per D-14: the web layer (InboxLive) is forbidden
from running a direct Cairnloop.Conversation query. Goes through repo().all/1
— never Cairnloop.Repo directly.
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).
Returns recent Cairnloop.Outbound.BulkEnvelope rows ordered requested_at desc.
Options
:limit(default50, hard cap500) — max rows returned. RaisesArgumentErrorif the caller requests more than the cap (defense-in-depth against unbounded reads — Phase 26 D-06 / RESEARCH "Specific Ideas" / threat T-26-06 DoS mitigation).:status(default:all) —:submitted | :refused_cap_exceeded | :all.:allreturns rows of both lanes.
Narrow-facade contract (D-14)
Reads through repo().all/1 per D-14: the web layer / host admin surfaces
MUST go through this function, never the concrete repo module directly. Phase
25's threat-register T-25-04 mitigation grep enforces zero direct repo
references from lib/cairnloop/web/inbox_live.ex; this read shares that
posture for the bulk-envelope substrate.
Phase 26 OBS-02 read facade (D-06)
This is the consumer-side read for the durable BulkEnvelope substrate
landed in Phase 25 plan 01 (D-13). Submit (:submitted) and refused
(:refused_cap_exceeded) attempts are stored on the same table so a single
call sees both lanes when :status is :all.
Returns a cohort preview map for the bulk-recovery confirmation modal (D-07).
The cohort is the subset of candidate_ids whose conversations are :resolved
(D-01). The sample is the first 5 labels by updated_at desc (research Open
Question 6 — locked rationale: most-recently-resolved is the cohort an operator
most likely had in mind when they selected).
Return shape:
%{
eligible_ids: [123, 124, ...], # all resolved ids in the cohort
sample: ["Refund (#123)", ...], # first 5 labels, updated_at desc
more: 12, # max(total - 5, 0)
total: 17 # full eligible cohort size
}Reads through the narrow facade per D-14. Goes through repo().all/1 — never
Cairnloop.Repo directly.
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.