Cairnloop.Governance (cairnloop v0.5.1)

Copy Markdown View Source

Public facade for the governed-tool proposal system (D-30).

Public API

  • validate/3 — pure, re-callable, ordered with pipeline 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-commits ToolProposal + ToolActionEvent synchronously, handles duplicates (TOOL-04, D-26, D-25).
  • request_approval/2 — opens a :pending ToolApproval lane for a :requires_approval proposal; sets expires_at = now + ttl (host-configurable; default 172_800 seconds / 48 hours, D15-13); emits :approval_requested event; schedules ApprovalExpiryWorker post-transaction via injectable enqueue_fn (Pattern 4).
  • approve/3 — persists :approved decision + event, then enqueues ApprovalResumeWorker via injectable enqueue_fn; NEVER calls run/3 (APRV-01). Guarded on current status == :pending.
  • reject/3 — persists :rejected decision + event; requires a :reason (FLOW-03). Guarded on :pending. No enqueue.
  • defer/3 — persists :deferred decision + event; requires a :reason (FLOW-03). Guarded on :pending. No enqueue.
  • expire/2 — persists :expired decision + 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_blocked event (D-18 Support-Truth Gate).
  • Happy path: proposal + :proposal_created event co-committed in one with (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 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

approve(approval_id, actor_id, opts \\ [])

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)

defer(approval_id, actor_id, opts \\ [])

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)

execute_approved(approval_id, opts \\ [])

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)

expire(approval_id, opts \\ [])

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")

get_active_approval(tool_proposal_id)

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).

get_bulk_outbound_envelope(id)

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).

get_latest_approval(tool_proposal_id)

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).

get_proposal(id)

Returns a ToolProposal by id, or nil if not found.

list_action_events(opts \\ [])

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 (default 100). Use for pagination.
  • :offset — skip this many rows (default 0).

Goes through the repo() indirection — never Cairnloop.Repo directly (D-30).

list_eligible_conversation_ids_for_bulk_recovery(candidate_ids)

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.

list_events(proposal_id)

Returns all ToolActionEvent records for a given proposal id, ordered by inserted_at.

list_proposals_for_conversation(conversation_id, opts \\ [])

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).

list_recent_bulk_outbound_envelopes(opts \\ [])

Returns recent Cairnloop.Outbound.BulkEnvelope rows ordered requested_at desc.

Options

  • :limit (default 50, hard cap 500) — max rows returned. Raises ArgumentError if 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. :all returns 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.

preview_bulk_recovery_cohort(candidate_ids)

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.

propose(tool_ref, actor_id, context)

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_blocked event (D-18 Support-Truth Gate).
  • {:ok, validated}: derives idempotency key, co-commits proposal + :proposal_created event; on duplicate unique constraint, returns the existing proposal (D-25).

Does NOT call run/3. Does NOT enqueue Oban (D-26).

reject(approval_id, actor_id, opts \\ [])

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)

request_approval(proposal, opts \\ [])

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 to proposal.actor_id)
  • :enqueue_fn — injectable enqueue callback for testing (default: &safe_enqueue/1)

validate(tool_ref, actor_id, context)

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.