lib/openagents/conversations/turn_receipt.ex

58e6347eeb72 · 10 KB

defmodule OpenAgents.Conversations.TurnReceipt do
  @moduledoc "Immutable artifact identity and bounded lifecycle evidence for one turn."

  use Ecto.Schema
  import Ecto.Changeset

  @statuses ~w(captured completed failed cancelled interrupted)
  @digest_regex ~r/\A[0-9a-f]{64}\z/
  @memory_snapshot_regex ~r/\Amessage:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
  @profile_memory_snapshot_regex ~r/\Aprofile-memory-snapshot:v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
  @preference_snapshot_regex ~r/\Apreference-snapshot:v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
  @experience_bank_regex ~r/\Aexperience-bank:v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
  @maximum_refs 100
  @maximum_ref_bytes 256

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]

  schema "turn_receipts" do
    belongs_to :turn, OpenAgents.Conversations.Turn
    field :schema_version, :integer, default: 1
    field :status, :string, default: "captured"
    field :model_id, :string
    field :persona_id, :string
    field :persona_digest, :string
    field :role_id, :string
    field :role_digest, :string
    field :role_selection, :map
    field :instruction_digest, :string
    field :input_digest, :string
    field :input_message_count, :integer
    field :input_bytes, :integer
    field :tool_catalog_digest, :string
    field :blueprint_revision, :string
    field :blueprint_digest, :string
    field :program_artifact_id, :string
    field :program_artifact_digest, :string
    field :program_artifact_receipt, :map
    field :memory_snapshot_ref, :string
    field :profile_memory_snapshot_ref, :string
    field :preference_snapshot_ref, :string

    field :used_preferences, :map,
      default: %{"schema" => "sarah.preference_usage.v1", "applied" => [], "overridden" => []}

    field :experience_bank_ref, :string

    field :used_experiences, :map,
      default: %{
        "schema" => "sarah.experience_usage.v1",
        "record_refs" => [],
        "pattern_refs" => [],
        "bank_digest" => nil
      }

    field :used_source_refs, {:array, :string}, default: []
    field :used_tool_step_refs, {:array, :string}, default: []

    field :used_memory_evidence, :map,
      default: %{"schema" => "sarah.memory_evidence_usage.v1", "items" => []}

    field :usage, :map
    field :provider_started_at, :utc_datetime_usec
    field :provider_completed_at, :utc_datetime_usec
    timestamps()
  end

  def create_changeset(receipt, attributes) do
    receipt
    |> cast(attributes, [
      :turn_id,
      :schema_version,
      :status,
      :model_id,
      :persona_id,
      :persona_digest,
      :role_id,
      :role_digest,
      :role_selection,
      :instruction_digest,
      :input_digest,
      :input_message_count,
      :input_bytes,
      :tool_catalog_digest,
      :blueprint_revision,
      :blueprint_digest,
      :program_artifact_id,
      :program_artifact_digest,
      :program_artifact_receipt,
      :memory_snapshot_ref,
      :profile_memory_snapshot_ref,
      :preference_snapshot_ref,
      :used_preferences,
      :experience_bank_ref,
      :used_experiences,
      :used_source_refs,
      :used_tool_step_refs,
      :used_memory_evidence,
      :usage,
      :provider_started_at,
      :provider_completed_at
    ])
    |> validate_required([
      :turn_id,
      :schema_version,
      :status,
      :model_id,
      :persona_id,
      :persona_digest,
      :role_id,
      :role_digest,
      :role_selection,
      :instruction_digest,
      :input_digest,
      :input_message_count,
      :input_bytes,
      :provider_started_at
    ])
    |> common_validations()
    |> foreign_key_constraint(:turn_id)
    |> unique_constraint(:turn_id)
  end

  def lifecycle_changeset(receipt, attributes) do
    receipt
    |> cast(attributes, [
      :status,
      :used_source_refs,
      :used_tool_step_refs,
      :used_memory_evidence,
      :usage,
      :provider_completed_at
    ])
    |> common_validations()
  end

  defp common_validations(changeset) do
    changeset
    |> validate_inclusion(:status, @statuses)
    |> validate_number(:schema_version, equal_to: 1)
    |> validate_number(:input_message_count, greater_than_or_equal_to: 0)
    |> validate_number(:input_bytes, greater_than_or_equal_to: 0)
    |> validate_digest(:persona_digest)
    |> validate_digest(:role_digest)
    |> validate_role_selection()
    |> validate_digest(:instruction_digest)
    |> validate_digest(:input_digest)
    |> validate_optional_digest(:tool_catalog_digest)
    |> validate_optional_digest(:blueprint_digest)
    |> validate_optional_digest(:program_artifact_digest)
    |> validate_program_artifact_receipt()
    |> validate_format(:memory_snapshot_ref, @memory_snapshot_regex)
    |> validate_optional_format(:profile_memory_snapshot_ref, @profile_memory_snapshot_regex)
    |> validate_optional_format(:preference_snapshot_ref, @preference_snapshot_regex)
    |> validate_preference_usage()
    |> validate_optional_format(:experience_bank_ref, @experience_bank_regex)
    |> validate_experience_usage()
    |> validate_refs(:used_source_refs)
    |> validate_refs(:used_tool_step_refs)
    |> validate_memory_evidence_usage()
    |> validate_usage()
  end

  defp validate_digest(changeset, field), do: validate_format(changeset, field, @digest_regex)

  defp validate_optional_format(changeset, field, format) do
    case get_field(changeset, field) do
      nil -> changeset
      _value -> validate_format(changeset, field, format)
    end
  end

  defp validate_role_selection(changeset) do
    validate_change(changeset, :role_selection, fn :role_selection, selection ->
      required_keys =
        ~w(schema role_id role_digest surface authority reason input_digest catalog_digest available_capabilities)

      cond do
        selection["schema"] != "sarah.role_selection.v1" ->
          [role_selection: "has an unsupported schema"]

        Enum.any?(required_keys, &(not Map.has_key?(selection, &1))) ->
          [role_selection: "is missing required provenance"]

        selection["role_id"] != get_field(changeset, :role_id) or
            selection["role_digest"] != get_field(changeset, :role_digest) ->
          [role_selection: "does not match the selected role"]

        byte_size(Jason.encode!(selection)) > 4_096 ->
          [role_selection: "is too large"]

        true ->
          []
      end
    end)
  end

  defp validate_program_artifact_receipt(changeset) do
    validate_change(changeset, :program_artifact_receipt, fn :program_artifact_receipt, receipt ->
      cond do
        receipt["schema"] != "sarah.program_capture.v1" ->
          [program_artifact_receipt: "has an unsupported schema"]

        receipt["artifact_id"] != get_field(changeset, :program_artifact_id) or
            receipt["artifact_digest"] != get_field(changeset, :program_artifact_digest) ->
          [program_artifact_receipt: "does not match artifact identity"]

        not is_boolean(receipt["degraded"]) ->
          [program_artifact_receipt: "must state degradation"]

        byte_size(Jason.encode!(receipt)) > 4_096 ->
          [program_artifact_receipt: "is too large"]

        true ->
          []
      end
    end)
  end

  defp validate_optional_digest(changeset, field) do
    case get_field(changeset, field) do
      nil -> changeset
      _digest -> validate_digest(changeset, field)
    end
  end

  defp validate_refs(changeset, field) do
    validate_change(changeset, field, fn ^field, refs ->
      cond do
        length(refs) > @maximum_refs ->
          [{field, "has too many references"}]

        Enum.any?(refs, &(not is_binary(&1) or &1 == "" or byte_size(&1) > @maximum_ref_bytes)) ->
          [{field, "contains an invalid reference"}]

        length(refs) != MapSet.size(MapSet.new(refs)) ->
          [{field, "contains duplicate references"}]

        true ->
          []
      end
    end)
  end

  defp validate_usage(changeset) do
    validate_change(changeset, :usage, fn :usage, usage ->
      if byte_size(Jason.encode!(usage)) <= 16_384,
        do: [],
        else: [usage: "is too large"]
    end)
  end

  defp validate_memory_evidence_usage(changeset) do
    validate_change(changeset, :used_memory_evidence, fn :used_memory_evidence, ledger ->
      if OpenAgents.Memory.Evidence.valid_usage_ledger?(ledger),
        do: [],
        else: [used_memory_evidence: "is invalid"]
    end)
  end

  defp validate_preference_usage(changeset) do
    validate_change(changeset, :used_preferences, fn :used_preferences, ledger ->
      applied = ledger["applied"]
      overridden = ledger["overridden"]

      valid_item? = fn item ->
        is_map(item) and
          Regex.match?(~r/\Apreference:v1:[0-9a-f-]{36}\z/, item["preference_ref"] || "") and
          Regex.match?(
            ~r/\Apreference-activation:v1:[0-9a-f-]{36}\z/,
            item["activation_receipt_ref"] || ""
          ) and Regex.match?(@digest_regex, item["effect_digest"] || "")
      end

      if ledger["schema"] == "sarah.preference_usage.v1" and is_list(applied) and
           is_list(overridden) and length(applied) <= 8 and length(overridden) <= 8 and
           Enum.all?(applied, valid_item?) and Enum.all?(overridden, valid_item?) and
           byte_size(Jason.encode!(ledger)) <= 8_192,
         do: [],
         else: [used_preferences: "is invalid"]
    end)
  end

  defp validate_experience_usage(changeset) do
    validate_change(changeset, :used_experiences, fn :used_experiences, usage ->
      records = usage["record_refs"]
      patterns = usage["pattern_refs"]
      digest = usage["bank_digest"]

      valid =
        usage["schema"] == "sarah.experience_usage.v1" and is_list(records) and
          is_list(patterns) and length(records) <= 6 and length(patterns) <= 3 and
          Enum.all?(records, &Regex.match?(~r/\Aexperience:[0-9a-f-]{36}\z/, &1)) and
          Enum.all?(patterns, &Regex.match?(~r/\Aexperience-pattern:[0-9a-f-]{36}\z/, &1)) and
          (is_nil(digest) or Regex.match?(@digest_regex, digest))

      if valid, do: [], else: [used_experiences: "is invalid"]
    end)
  end
end