# `Cairnloop.Governance`
[🔗](https://github.com/szTheory/cairnloop/blob/main/lib/cairnloop/governance.ex#L1)

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

# `approve`

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`

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`

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`

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`

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`

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`

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

# `list_events`

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

# `list_proposals_for_conversation`

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`

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`

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`

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`

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
