Cairnloop.Governance (cairnloop v0.1.0)

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

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_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_events(proposal_id)

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

list_proposals_for_conversation(conversation_id)

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

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.