lib/openagents/preferences.ex

main at 58e6347eeb72 · 27 KB

defmodule OpenAgents.Preferences do
  @moduledoc "Governed private behavior preferences, frozen usage, and attributed outcomes."

  import Ecto.Query

  alias OpenAgents.Conversations.{Message, Turn, TurnReceipt, Visitor}

  alias OpenAgents.Preferences.{
    ActivationReceipt,
    ConfirmationReceipt,
    Observation,
    OutcomeReceipt,
    Preference,
    ReviewReceipt,
    Scope,
    Snapshot,
    SnapshotRecord
  }

  alias OpenAgents.Provenance.Canonical
  alias OpenAgents.Repo

  @policy_id "sarah.preference.policy.v1"
  @policy_version 1
  @maximum_preferences 100
  @effect_values %{
    "response_length" => ~w(concise detailed),
    "format" => ~w(bullets paragraphs),
    "tone" => ~w(direct gentle),
    "initiative" => ~w(ask_first suggest_next_steps)
  }
  @categories %{
    "response_length" => "presentation",
    "format" => "presentation",
    "tone" => "presentation",
    "initiative" => "interaction"
  }

  @spec observe(Visitor.t(), map()) :: {:ok, Observation.t()} | {:error, term()}
  def observe(%Visitor{} = owner, attributes) when is_map(attributes) do
    with {:ok, prepared} <- prepare_observation(owner, attributes) do
      Repo.insert(Observation.changeset(%Observation{}, prepared))
    end
  end

  def observe(_owner, _attributes), do: {:error, :invalid_owner_scope}

  @spec propose(Visitor.t(), Ecto.UUID.t(), String.t(), String.t()) ::
          {:ok, Preference.t()} | {:error, term()}
  def propose(%Visitor{} = owner, observation_id, effect_key, effect_value) do
    transaction(fn -> propose_locked(owner, observation_id, effect_key, effect_value, nil) end)
  end

  def propose(_owner, _observation_id, _effect_key, _effect_value),
    do: {:error, :invalid_preference_proposal}

  @spec review(Visitor.t(), Ecto.UUID.t(), pos_integer(), map()) ::
          {:ok, %{preference: Preference.t(), receipt: ReviewReceipt.t()}} | {:error, term()}
  def review(%Visitor{} = owner, preference_id, expected_generation, review)
      when is_map(review) do
    transaction(fn ->
      with {:ok, preference} <- owned_for_update(owner.id, preference_id),
           :ok <- generation_matches(preference, expected_generation),
           :ok <- require_status(preference, "candidate"),
           {:ok, decision} <- member(review["decision"], ~w(accepted rejected), :invalid_review),
           {:ok, reviewer_id} <- reviewer(review["reviewer_id"]),
           {:ok, reason_code} <- code(review["reason_code"], :invalid_review) do
        target_status = if decision == "accepted", do: "reviewed", else: "deleted"

        terminal_generation =
          if target_status == "deleted", do: next_generation!(owner.id), else: nil

        generation =
          if terminal_generation, do: terminal_generation, else: next_generation!(owner.id)

        updated =
          transition!(preference, target_status, generation,
            terminal_generation: terminal_generation
          )

        projection = %{
          "preference_id" => preference.id,
          "owner_visitor_id" => owner.id,
          "reviewer_id" => reviewer_id,
          "decision" => decision,
          "reason_code" => reason_code,
          "effect_digest" => preference.effect_digest
        }

        receipt =
          insert!(
            ReviewReceipt.changeset(%ReviewReceipt{}, %{
              preference_id: preference.id,
              owner_visitor_id: owner.id,
              reviewer_id: reviewer_id,
              decision: decision,
              reason_code: reason_code,
              effect_digest: preference.effect_digest,
              receipt_digest: Canonical.digest!(projection)
            })
          )

        %{preference: updated, receipt: receipt}
      end
    end)
  end

  @spec confirm(Visitor.t(), Ecto.UUID.t(), pos_integer(), map()) ::
          {:ok, Preference.t()} | {:error, term()}
  def confirm(%Visitor{} = owner, preference_id, expected_generation, confirmation)
      when is_map(confirmation) do
    transaction(fn ->
      with {:ok, preference} <- owned_for_update(owner.id, preference_id),
           :ok <- generation_matches(preference, expected_generation),
           :ok <- require_status(preference, "reviewed"),
           {:ok, confirmation_evidence} <-
             validate_confirmation(confirmation, preference.effect_digest),
           generation <- next_generation!(owner.id) do
        projection = %{
          "preference_id" => preference.id,
          "owner_visitor_id" => owner.id,
          "kind" => confirmation_evidence.kind,
          "evidence_ref" => confirmation_evidence.ref,
          "effect_digest" => preference.effect_digest
        }

        receipt =
          insert!(
            ConfirmationReceipt.changeset(
              %ConfirmationReceipt{},
              Map.put(projection, "receipt_digest", Canonical.digest!(projection))
            )
          )

        transition!(preference, "confirmed", generation,
          confirmation_ref: "preference-confirmation:v1:#{receipt.id}"
        )
      end
    end)
  end

  @spec activate(Visitor.t(), Ecto.UUID.t(), pos_integer()) ::
          {:ok, %{preference: Preference.t(), receipt: ActivationReceipt.t()}} | {:error, term()}
  def activate(%Visitor{} = owner, preference_id, expected_generation) do
    transaction(fn ->
      with {:ok, preference} <- owned_for_update(owner.id, preference_id),
           :ok <- generation_matches(preference, expected_generation),
           :ok <- require_status(preference, "confirmed"),
           :ok <- require_fresh(preference),
           :ok <- require_no_active_effect(owner.id, preference.effect_key),
           generation <- next_generation!(owner.id) do
        updated = transition!(preference, "active", generation, active_generation: generation)

        projection = %{
          "preference_id" => preference.id,
          "owner_visitor_id" => owner.id,
          "scope_generation" => generation,
          "confirmation_ref" => preference.confirmation_ref,
          "effect_digest" => preference.effect_digest,
          "policy_id" => preference.policy_id,
          "policy_version" => preference.policy_version
        }

        receipt =
          insert!(
            ActivationReceipt.changeset(%ActivationReceipt{}, %{
              preference_id: preference.id,
              owner_visitor_id: owner.id,
              scope_generation: generation,
              confirmation_ref: preference.confirmation_ref,
              effect_digest: preference.effect_digest,
              policy_id: preference.policy_id,
              policy_version: preference.policy_version,
              receipt_digest: Canonical.digest!(projection)
            })
          )

        %{preference: updated, receipt: receipt}
      end
    end)
  end

  @spec suspend(Visitor.t(), Ecto.UUID.t(), pos_integer(), String.t()) ::
          {:ok, Preference.t()} | {:error, term()}
  def suspend(%Visitor{} = owner, preference_id, expected_generation, reason_code) do
    terminal_transition(
      owner,
      preference_id,
      expected_generation,
      "active",
      "suspended",
      reason_code
    )
  end

  @spec delete(Visitor.t(), Ecto.UUID.t(), pos_integer(), String.t()) ::
          {:ok, Preference.t()} | {:error, term()}
  def delete(%Visitor{} = owner, preference_id, expected_generation, reason_code) do
    with {:ok, _reason} <- code(reason_code, :invalid_reason) do
      transaction(fn ->
        with {:ok, preference} <- owned_for_update(owner.id, preference_id),
             :ok <- generation_matches(preference, expected_generation),
             true <-
               preference.status in ~w(candidate reviewed confirmed active suspended) or
                 {:error, :invalid_transition},
             generation <- next_generation!(owner.id) do
          transition!(preference, "deleted", generation,
            terminal_generation: preference.terminal_generation || generation
          )
        end
      end)
    end
  end

  @spec correct(Visitor.t(), Ecto.UUID.t(), pos_integer(), map(), String.t(), String.t()) ::
          {:ok, %{suspended: Preference.t(), candidate: Preference.t()}} | {:error, term()}
  def correct(
        %Visitor{} = owner,
        preference_id,
        expected_generation,
        observation_attributes,
        key,
        value
      )
      when is_map(observation_attributes) do
    with {:ok, prepared} <- prepare_observation(owner, observation_attributes) do
      transaction(fn ->
        with {:ok, old} <- owned_for_update(owner.id, preference_id),
             :ok <- generation_matches(old, expected_generation),
             :ok <- require_status(old, "active"),
             generation <- next_generation!(owner.id),
             suspended <-
               transition!(old, "suspended", generation, terminal_generation: generation),
             observation <- insert!(Observation.changeset(%Observation{}, prepared)),
             candidate <- propose_locked(owner, observation.id, key, value, old.id) do
          %{suspended: suspended, candidate: candidate}
        end
      end)
    end
  end

  @spec capture_snapshot(Visitor.t()) :: {:ok, Snapshot.t()} | {:error, term()}
  def capture_snapshot(%Visitor{} = owner) do
    transaction(fn ->
      scope = ensure_scope!(owner.id)
      now = DateTime.utc_now()

      stored =
        insert!(
          SnapshotRecord.changeset(%SnapshotRecord{}, %{
            owner_visitor_id: owner.id,
            scope_generation: scope.generation,
            captured_at: now,
            inserted_at: now
          })
        )

      %Snapshot{
        owner_visitor_id: owner.id,
        generation: scope.generation,
        captured_at: now,
        ref: snapshot_ref(stored.id)
      }
    end)
  end

  def capture_snapshot(_owner), do: {:error, :invalid_owner_scope}

  @spec load_snapshot(Visitor.t(), String.t()) :: {:ok, Snapshot.t()} | {:error, :scope_refused}
  def load_snapshot(%Visitor{id: owner_id}, "preference-snapshot:v1:" <> id) do
    with {:ok, parsed} <- Ecto.UUID.cast(id),
         %SnapshotRecord{} = stored <-
           Repo.get_by(SnapshotRecord, id: parsed, owner_visitor_id: owner_id) do
      {:ok,
       %Snapshot{
         owner_visitor_id: owner_id,
         generation: stored.scope_generation,
         captured_at: stored.captured_at,
         ref: snapshot_ref(stored.id)
       }}
    else
      _invalid -> {:error, :scope_refused}
    end
  end

  def load_snapshot(_owner, _ref), do: {:error, :scope_refused}

  @spec validate_turn_capture(Turn.t(), String.t() | nil, map()) :: :ok | {:error, term()}
  def validate_turn_capture(%Turn{}, nil, usage) do
    if usage == empty_usage(), do: :ok, else: {:error, :invalid_preference_capture}
  end

  def validate_turn_capture(%Turn{} = turn, snapshot_ref, usage) when is_binary(snapshot_ref) do
    owner = OpenAgents.Conversations.get_turn_owner!(turn)
    current_message = Repo.get!(Message, turn.user_message_id)

    with {:ok, snapshot} <- load_snapshot(owner, snapshot_ref),
         {:ok, projection} <- project_active(owner, snapshot, current_message.content),
         true <- projection.usage == usage or {:error, :invalid_preference_capture} do
      :ok
    end
  end

  def validate_turn_capture(_turn, _snapshot_ref, _usage),
    do: {:error, :invalid_preference_capture}

  @spec project_active(Visitor.t(), Snapshot.t(), String.t()) :: {:ok, map()} | {:error, term()}
  def project_active(%Visitor{} = owner, %Snapshot{} = snapshot, current_instruction)
      when is_binary(current_instruction) do
    with :ok <- validate_snapshot(owner, snapshot) do
      preferences = active_at(owner.id, snapshot)

      {applied, overridden} =
        preferences
        |> Enum.map(&project_with_activation!/1)
        |> Enum.split_with(fn projection ->
          not conflicts_with_current?(projection["effect"], current_instruction)
        end)

      usage = %{
        "schema" => "sarah.preference_usage.v1",
        "applied" => Enum.map(applied, &usage_item/1),
        "overridden" =>
          Enum.map(overridden, fn item ->
            item |> usage_item() |> Map.put("reason", "current_instruction")
          end)
      }

      {:ok, %{applied: applied, usage: usage}}
    end
  end

  def project_active(_owner, _snapshot, _current_instruction), do: {:error, :scope_refused}

  @spec inspect_all(Visitor.t()) :: {:ok, [Preference.t()]} | {:error, term()}
  def inspect_all(%Visitor{} = owner) do
    {:ok,
     Repo.all(
       from(preference in Preference,
         where: preference.owner_visitor_id == ^owner.id,
         order_by: [asc: preference.inserted_at, asc: preference.id],
         limit: @maximum_preferences,
         preload: [:observation]
       )
     )}
  end

  @spec record_outcome(Visitor.t(), Ecto.UUID.t(), Ecto.UUID.t(), map()) ::
          {:ok, OutcomeReceipt.t()} | {:error, term()}
  def record_outcome(%Visitor{} = owner, preference_id, turn_id, attributes)
      when is_map(attributes) do
    transaction(fn ->
      with %Preference{} = preference <- owned(owner.id, preference_id),
           %Turn{} = turn <- Repo.get(Turn, turn_id),
           :ok <- turn_owned_by(owner.id, turn),
           %TurnReceipt{} = turn_receipt <- Repo.get_by(TurnReceipt, turn_id: turn.id),
           {:ok, activation_ref} <- applied_activation_ref(turn_receipt, preference.id),
           {:ok, activation_id} <- activation_id(activation_ref),
           %ActivationReceipt{} <-
             Repo.get_by(ActivationReceipt,
               id: activation_id,
               preference_id: preference.id,
               owner_visitor_id: owner.id
             ),
           {:ok, outcome} <-
             member(
               attributes["outcome"],
               ~w(benefited neutral corrected rejected),
               :invalid_outcome
             ),
           {:ok, evidence_ref} <- bounded(attributes["evidence_ref"], 256, :invalid_outcome),
           {:ok, reason_code} <- code(attributes["reason_code"], :invalid_outcome) do
        projection = %{
          "preference_id" => preference.id,
          "turn_id" => turn.id,
          "owner_visitor_id" => owner.id,
          "activation_receipt_id" => activation_id,
          "outcome" => outcome,
          "evidence_ref" => evidence_ref,
          "reason_code" => reason_code
        }

        %OutcomeReceipt{}
        |> OutcomeReceipt.changeset(
          Map.put(projection, "receipt_digest", Canonical.digest!(projection))
        )
        |> insert!()
      else
        nil -> Repo.rollback(:not_found)
        {:error, reason} -> Repo.rollback(reason)
      end
    end)
  end

  def record_outcome(_owner, _preference_id, _turn_id, _attributes),
    do: {:error, :invalid_outcome}

  defp prepare_observation(owner, attributes) do
    now = DateTime.utc_now()

    with {:ok, source_kind} <-
           member(
             attributes["source_kind"],
             ~w(current_user_message correction tool_outcome),
             :invalid_observation
           ),
         {:ok, summary} <- bounded(attributes["summary"], 500, :invalid_observation),
         {:ok, confidence} <- confidence(attributes["confidence_millis"]),
         {:ok, proposer_id} <- bounded(attributes["proposer_id"], 128, :invalid_observation),
         {:ok, proposer_digest} <- digest(attributes["proposer_digest"], :invalid_observation),
         :ok <- valid_freshness(now, attributes["freshness_until"]),
         :ok <- validate_source(owner.id, source_kind, attributes["source_message_id"]) do
      evidence = %{
        "owner_visitor_id" => owner.id,
        "source_message_id" => attributes["source_message_id"],
        "source_kind" => source_kind,
        "summary" => summary,
        "observed_at" => DateTime.to_iso8601(now)
      }

      {:ok,
       %{
         owner_visitor_id: owner.id,
         source_message_id: attributes["source_message_id"],
         source_kind: source_kind,
         summary: summary,
         evidence_digest: Canonical.digest!(evidence),
         confidence_millis: confidence,
         observed_at: now,
         freshness_until: attributes["freshness_until"],
         proposer_id: proposer_id,
         proposer_digest: proposer_digest,
         policy_id: @policy_id,
         policy_version: @policy_version
       }}
    end
  end

  defp propose_locked(owner, observation_id, effect_key, effect_value, supersedes_id) do
    with %Observation{} = observation <-
           Repo.get_by(Observation, id: observation_id, owner_visitor_id: owner.id),
         {:ok, value} <- admitted_effect(effect_key, effect_value),
         :ok <- enforce_limit(owner.id),
         generation <- next_generation!(owner.id) do
      effect = %{"key" => effect_key, "value" => value}

      %Preference{}
      |> Preference.create_changeset(%{
        owner_visitor_id: owner.id,
        observation_id: observation.id,
        supersedes_preference_id: supersedes_id,
        category: Map.fetch!(@categories, effect_key),
        effect_key: effect_key,
        effect_value: value,
        proposed_effect: effect,
        effect_digest: Canonical.digest!(effect),
        status: "candidate",
        confidence_millis: observation.confidence_millis,
        freshness_until: observation.freshness_until,
        policy_id: @policy_id,
        policy_version: @policy_version,
        generation: 1,
        created_generation: generation
      })
      |> insert!()
    else
      nil -> Repo.rollback(:observation_not_found)
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  defp terminal_transition(owner, preference_id, expected_generation, source, target, reason_code) do
    with {:ok, _reason} <- code(reason_code, :invalid_reason) do
      transaction(fn ->
        with {:ok, preference} <- owned_for_update(owner.id, preference_id),
             :ok <- generation_matches(preference, expected_generation),
             :ok <- require_status(preference, source),
             generation <- next_generation!(owner.id) do
          transition!(preference, target, generation, terminal_generation: generation)
        end
      end)
    end
  end

  defp transition!(preference, status, _scope_generation, options) do
    attributes = %{
      status: status,
      generation: preference.generation + 1,
      active_generation: Keyword.get(options, :active_generation, preference.active_generation),
      terminal_generation:
        Keyword.get(options, :terminal_generation, preference.terminal_generation),
      confirmation_ref: Keyword.get(options, :confirmation_ref, preference.confirmation_ref)
    }

    preference |> Preference.lifecycle_changeset(attributes) |> update!()
  end

  defp active_at(owner_id, snapshot) do
    Repo.all(
      from(preference in Preference,
        where:
          preference.owner_visitor_id == ^owner_id and
            not is_nil(preference.active_generation) and
            preference.active_generation <= ^snapshot.generation and
            (is_nil(preference.terminal_generation) or
               preference.terminal_generation > ^snapshot.generation) and
            (is_nil(preference.freshness_until) or
               preference.freshness_until > ^snapshot.captured_at),
        order_by: [asc: preference.effect_key, asc: preference.id],
        limit: 8
      )
    )
  end

  defp project_with_activation!(preference) do
    receipt =
      Repo.get_by!(ActivationReceipt,
        preference_id: preference.id,
        scope_generation: preference.active_generation
      )

    %{
      "preference_ref" => "preference:v1:#{preference.id}",
      "activation_receipt_ref" => "preference-activation:v1:#{receipt.id}",
      "effect_digest" => preference.effect_digest,
      "effect" => preference.proposed_effect
    }
  end

  defp usage_item(item),
    do: Map.take(item, ~w(preference_ref activation_receipt_ref effect_digest))

  defp conflicts_with_current?(%{"key" => key, "value" => value}, instruction) do
    case current_choice(key, instruction) do
      nil -> false
      ^value -> false
      _different -> true
    end
  end

  defp current_choice("response_length", text) do
    cond do
      Regex.match?(~r/\b(concise|brief|short answer)\b/iu, text) -> "concise"
      Regex.match?(~r/\b(detailed|thorough|in[- ]depth)\b/iu, text) -> "detailed"
      true -> nil
    end
  end

  defp current_choice("format", text) do
    cond do
      Regex.match?(~r/\b(bullets?|bullet points?|list)\b/iu, text) -> "bullets"
      Regex.match?(~r/\b(paragraphs?|prose)\b/iu, text) -> "paragraphs"
      true -> nil
    end
  end

  defp current_choice("tone", text) do
    cond do
      Regex.match?(~r/\b(direct|blunt)\b/iu, text) -> "direct"
      Regex.match?(~r/\b(gentle|softly)\b/iu, text) -> "gentle"
      true -> nil
    end
  end

  defp current_choice("initiative", text) do
    cond do
      Regex.match?(~r/\b(ask|check) (me )?first\b/iu, text) ->
        "ask_first"

      Regex.match?(~r/\b(suggest next steps?|proactive suggestions?)\b/iu, text) ->
        "suggest_next_steps"

      true ->
        nil
    end
  end

  defp validate_confirmation(confirmation, effect_digest) do
    with {:ok, kind} <-
           member(
             confirmation["kind"],
             ~w(exact_confirmation first_party_ui),
             :confirmation_required
           ),
         true <-
           confirmation["effect_digest"] == effect_digest or {:error, :confirmation_mismatch},
         {:ok, ref} <- bounded(confirmation["ref"], 256, :confirmation_required) do
      {:ok, %{kind: kind, ref: ref}}
    end
  end

  defp validate_source(owner_id, source_kind, source_message_id)
       when source_kind in ~w(current_user_message correction) do
    query =
      from(message in Message,
        join: conversation in assoc(message, :conversation),
        where:
          message.id == ^source_message_id and conversation.visitor_id == ^owner_id and
            message.role == "user" and message.status == "complete"
      )

    if is_binary(source_message_id) and Repo.exists?(query),
      do: :ok,
      else: {:error, :invalid_observation_source}
  end

  defp validate_source(_owner_id, "tool_outcome", nil), do: :ok

  defp validate_source(_owner_id, "tool_outcome", source_message_id)
       when is_binary(source_message_id), do: :ok

  defp validate_source(_owner_id, _source_kind, _source_message_id),
    do: {:error, :invalid_observation_source}

  defp validate_snapshot(%Visitor{id: owner_id}, %Snapshot{owner_visitor_id: owner_id}), do: :ok
  defp validate_snapshot(_owner, _snapshot), do: {:error, :scope_refused}

  defp require_no_active_effect(owner_id, effect_key) do
    if Repo.exists?(
         from(p in Preference,
           where:
             p.owner_visitor_id == ^owner_id and p.effect_key == ^effect_key and
               p.status == "active"
         )
       ),
       do: {:error, :active_effect_conflict},
       else: :ok
  end

  defp require_fresh(%Preference{freshness_until: nil}), do: :ok

  defp require_fresh(%Preference{freshness_until: until}),
    do: if(DateTime.after?(until, DateTime.utc_now()), do: :ok, else: {:error, :preference_stale})

  defp ensure_scope!(owner_id) do
    changeset = Scope.changeset(%Scope{}, %{owner_visitor_id: owner_id, generation: 0})

    case Repo.insert(changeset,
           on_conflict: :nothing,
           conflict_target: [:owner_visitor_id]
         ) do
      {:ok, _scope_or_placeholder} -> Repo.get!(Scope, owner_id)
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  defp next_generation!(owner_id) do
    _scope = ensure_scope!(owner_id)

    scope =
      Repo.one!(
        from(scope in Scope,
          where: scope.owner_visitor_id == ^owner_id,
          lock: "FOR UPDATE"
        )
      )

    updated = scope |> Scope.changeset(%{generation: scope.generation + 1}) |> update!()
    updated.generation
  end

  defp enforce_limit(owner_id) do
    if Repo.aggregate(from(p in Preference, where: p.owner_visitor_id == ^owner_id), :count) <
         @maximum_preferences,
       do: :ok,
       else: {:error, :preference_limit_reached}
  end

  defp owned(owner_id, id), do: Repo.get_by(Preference, id: id, owner_visitor_id: owner_id)

  defp owned_for_update(owner_id, id) do
    case Repo.one(
           from(p in Preference,
             where: p.id == ^id and p.owner_visitor_id == ^owner_id,
             lock: "FOR UPDATE"
           )
         ) do
      nil -> {:error, :not_found}
      preference -> {:ok, preference}
    end
  end

  defp generation_matches(%Preference{generation: generation}, generation), do: :ok
  defp generation_matches(_preference, _expected), do: {:error, :stale_generation}
  defp require_status(%Preference{status: status}, status), do: :ok
  defp require_status(_preference, _status), do: {:error, :invalid_transition}

  defp turn_owned_by(owner_id, %Turn{conversation_id: conversation_id}) do
    if Repo.exists?(
         from(c in OpenAgents.Conversations.Conversation,
           where: c.id == ^conversation_id and c.visitor_id == ^owner_id
         )
       ),
       do: :ok,
       else: {:error, :not_found}
  end

  defp applied_activation_ref(%TurnReceipt{used_preferences: usage}, preference_id) do
    ref = "preference:v1:#{preference_id}"

    case Enum.find(usage["applied"] || [], &(&1["preference_ref"] == ref)) do
      %{"activation_receipt_ref" => activation_ref} -> {:ok, activation_ref}
      _missing -> {:error, :preference_not_applied}
    end
  end

  defp activation_id("preference-activation:v1:" <> id), do: Ecto.UUID.cast(id)
  defp activation_id(_ref), do: {:error, :invalid_activation_receipt}

  defp admitted_effect(key, value) when is_binary(key) and is_binary(value) do
    if value in Map.get(@effect_values, key, []),
      do: {:ok, value},
      else: {:error, :effect_not_admitted}
  end

  defp admitted_effect(_key, _value), do: {:error, :effect_not_admitted}

  defp confidence(value) when is_integer(value) and value in 0..1000, do: {:ok, value}
  defp confidence(_value), do: {:error, :invalid_confidence}

  defp valid_freshness(_now, nil), do: :ok

  defp valid_freshness(now, %DateTime{} = until),
    do: if(DateTime.after?(until, now), do: :ok, else: {:error, :invalid_freshness})

  defp valid_freshness(_now, _until), do: {:error, :invalid_freshness}

  defp bounded(value, maximum, error) when is_binary(value) do
    normalized = value |> String.replace(~r/\s+/u, " ") |> String.trim()

    if byte_size(normalized) in 1..maximum,
      do: {:ok, normalized},
      else: {:error, error}
  end

  defp bounded(_value, _maximum, error), do: {:error, error}

  defp code(value, error) when is_binary(value) do
    if Regex.match?(~r/\A[a-z0-9_]{1,64}\z/, value), do: {:ok, value}, else: {:error, error}
  end

  defp code(_value, error), do: {:error, error}

  defp reviewer(value) when is_binary(value) do
    with {:ok, bounded_value} <- bounded(value, 128, :invalid_reviewer),
         true <-
           String.starts_with?(bounded_value, ["host-policy:", "operator:"]) or
             {:error, :invalid_reviewer} do
      {:ok, bounded_value}
    end
  end

  defp reviewer(_value), do: {:error, :invalid_reviewer}

  defp digest(value, error) when is_binary(value) do
    if Regex.match?(~r/\A[0-9a-f]{64}\z/, value),
      do: {:ok, value},
      else: {:error, error}
  end

  defp digest(_value, error), do: {:error, error}

  defp member(value, allowed, error) do
    if value in allowed, do: {:ok, value}, else: {:error, error}
  end

  defp snapshot_ref(id), do: "preference-snapshot:v1:#{id}"

  defp empty_usage,
    do: %{"schema" => "sarah.preference_usage.v1", "applied" => [], "overridden" => []}

  defp insert!(changeset) do
    case Repo.insert(changeset) do
      {:ok, record} -> record
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  defp update!(changeset) do
    case Repo.update(changeset) do
      {:ok, record} -> record
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  defp transaction(fun) do
    case Repo.transaction(fn ->
           case fun.() do
             {:error, reason} -> Repo.rollback(reason)
             result -> result
           end
         end) do
      {:ok, result} -> {:ok, result}
      {:error, reason} -> {:error, reason}
    end
  end
end