Skip to main content

lib/cairnloop/tools/internal_note.ex

defmodule Cairnloop.Tools.InternalNote do
  @moduledoc """
  Example governed-write tool: appends an operator-only internal note to the
  host-owned `cairnloop_messages` store.

  ## Usage

  This is the Phase 16 proof-of-concept (ACT-01, D16-01) and the reference implementation
  for host developers building governed-write tools. Copy this module and adapt the schema,
  changeset, and `run/3` body.

  ## Design Notes

  - `risk_tier: :low_write` → derives `approval_mode: :requires_approval` automatically (D-09/D-10).
  - `scope/0` returns `[]` — no special scopes required (D16-01: operator-only, low blast radius).
  - `authorize/2` overrides the deny-by-default to `:ok` — any authenticated operator may propose
    this action; the approval gate provides the safety barrier.
  - `run/3` is the ONLY place an actual side effect occurs. It is called only by
    `Cairnloop.Workers.ToolExecutionWorker` after the full approval + re-validation chain (D16-03).
  - Idempotency is implemented via an indexed `run_key` column existence check (D16-05).
    NEVER use a JSONB `metadata:` containment query — Ecto does not emit `@>` for map equality,
    and such a query would bypass the index entirely.
  - The note row carries `role: :internal_note` so it is distinguishable from customer-visible
    messages and can be filtered by host queries (D16-01 "never customer-visible").
  - Repo indirection: `Application.fetch_env!(:cairnloop, :repo)` — never `Cairnloop.Repo`
    directly. The host owns the repo; the library is a guest (D-02 / D16-02).

  ## Run key idempotency

  The worker passes `:run_idempotency_key` in the execution `context`. `run/3` does an
  indexed existence check before inserting. If the row already exists, returns
  `{:ok, %{idempotent: true}}` without inserting — safe under Oban job replay (T-16-01).

  ## Atomicity precondition (WR-01)

  The `run_idempotency_key` passed to `run/3` is attempt-scoped: it is derived from the
  proposal's `idempotency_key` and the current `attempt` number, so a Oban retry gets a
  **different** key. This design allows a retry to proceed cleanly if a prior attempt left
  no evidence row.

  **IMPORTANT for host tool authors copying this module:** your `run/3` MUST be a
  **single atomic write** keyed on `run_key`. If your tool performs multiple writes (e.g.
  row A then row B), a transient failure between them would be retried with a *new*
  `run_idempotency_key` — the existence check for the new key finds nothing, and the
  partial prior write (row A) is not rolled back. This could result in duplicate or
  inconsistent state. Rule: one `run/3` invocation = one atomic operation (one `INSERT`,
  one Ecto.Multi inside a single transaction, etc.).
  """

  use Cairnloop.Tool,
    risk_tier: :low_write,
    title: "Add internal note",
    description: "Appends an operator-only note to the conversation thread."

  embedded_schema do
    field(:conversation_id, :string)
    field(:content, :string)
  end

  @impl Cairnloop.Tool
  def changeset(struct, attrs) do
    struct
    |> cast(attrs, [:conversation_id, :content])
    |> validate_required([:conversation_id, :content])
    |> validate_length(:content, min: 1, max: 5_000)
  end

  @impl Cairnloop.Tool
  # No special scopes — any operator-scoped actor may propose this action (D16-01).
  def scope, do: []

  @impl Cairnloop.Tool
  # Override deny-by-default: any authenticated actor may propose an internal note.
  # The approval gate is the safety barrier for this low-blast-radius write (D16-01).
  def authorize(_actor_id, _context), do: :ok

  @impl Cairnloop.Tool
  @doc """
  Appends an `internal_note` role row to `cairnloop_messages`.

  Idempotent on `context[:run_idempotency_key]`: if a row with that `run_key` already
  exists, returns `{:ok, %{idempotent: true}}` without inserting (T-16-01, D16-05).

  Returns:
  - `{:ok, %{message_id: id}}` — note inserted successfully
  - `{:ok, %{idempotent: true}}` — duplicate key; note already written
  - `{:error, changeset}` — insert failed (propagated for Oban retry logic)
  """
  def run(%__MODULE__{conversation_id: conv_id, content: content}, _actor_id, context) do
    repo = Application.fetch_env!(:cairnloop, :repo)
    run_key = Map.get(context, :run_idempotency_key)

    # Idempotency: indexed run_key existence check (D16-05, O(1) via partial unique index).
    # NEVER: repo.get_by(Cairnloop.Message, metadata: %{run_key: run_key})
    # — Ecto does not emit a JSONB @> containment operator for map equality queries;
    #   the above would be either a missing-method error or a full-table scan (documented anti-pattern).
    case run_key && repo.get_by(Cairnloop.Message, run_key: run_key) do
      %Cairnloop.Message{} ->
        # Already written — safe replay; return idempotent ok
        {:ok, %{idempotent: true, note: "already written"}}

      _ ->
        attrs = %{
          conversation_id: conv_id,
          content: content,
          # Distinct role — operator-only, never customer-visible (D16-01)
          role: :internal_note,
          run_key: run_key,
          metadata: %{source: "cairnloop_governed_action", run_key: run_key}
        }

        case repo.insert(Cairnloop.Message.changeset(%Cairnloop.Message{}, attrs)) do
          {:ok, msg} -> {:ok, %{message_id: msg.id}}
          {:error, cs} -> {:error, cs}
        end
    end
  end
end