lib/openagents/artifact_catalog/listing.ex

58e6347eeb72 · 10 KB

defmodule OpenAgents.ArtifactCatalog.Listing do
  @moduledoc "A safe catalog projection for a licensed trace or dataset."

  use Ecto.Schema
  import Ecto.Changeset

  alias OpenAgents.Provenance.Canonical

  @digest_regex ~r/\A[0-9a-f]{64}\z/
  @private_keys ~w(
    email message_id private_source_ref raw_source secret source_path source_ref source_uri token
    user_id
  )

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

  schema "verified_artifact_listings" do
    field :artifact_type, :string
    field :state, :string, default: "active"
    field :owner_ref, :string
    field :owner_description, :string
    field :source_ref, :string
    field :artifact_digest, :string
    field :provenance_digest, :string
    field :provenance, :map, default: %{}
    field :schema, :map, default: %{}
    field :size_bytes, :integer
    field :record_count, :integer
    field :coverage, :map, default: %{}
    field :redaction, :map, default: %{}
    field :license_contract_ref, :string
    field :license_terms, :map, default: %{}
    field :license_digest, :string
    field :license_effective_at, :utc_datetime_usec
    field :license_expires_at, :utc_datetime_usec
    field :price, :map, default: %{}
    field :buyer_name, :string
    field :buyer_class, :string
    field :verification_policy, :map, default: %{}
    field :evidence_fresh_at, :utc_datetime_usec
    field :listing_digest, :string
    field :publication_receipt_ref, :string
    field :removed_at, :utc_datetime_usec
    field :removal_reason, :string

    has_many :receipts, OpenAgents.ArtifactCatalog.Receipt
    timestamps()
  end

  @type t :: %__MODULE__{}

  def publication_changeset(listing, attributes) do
    listing
    |> cast(attributes, [
      :artifact_type,
      :owner_ref,
      :owner_description,
      :source_ref,
      :artifact_digest,
      :provenance_digest,
      :provenance,
      :schema,
      :size_bytes,
      :record_count,
      :coverage,
      :redaction,
      :license_contract_ref,
      :license_terms,
      :license_effective_at,
      :license_expires_at,
      :price,
      :buyer_name,
      :buyer_class,
      :verification_policy,
      :evidence_fresh_at,
      :publication_receipt_ref
    ])
    |> validate_required([
      :artifact_type,
      :owner_ref,
      :owner_description,
      :source_ref,
      :artifact_digest,
      :provenance_digest,
      :provenance,
      :schema,
      :size_bytes,
      :coverage,
      :redaction,
      :license_contract_ref,
      :license_terms,
      :license_effective_at,
      :license_expires_at,
      :buyer_name,
      :buyer_class,
      :verification_policy,
      :evidence_fresh_at,
      :publication_receipt_ref
    ])
    |> validate_inclusion(:artifact_type, ~w(trace dataset))
    |> validate_format(:artifact_digest, @digest_regex)
    |> validate_format(:provenance_digest, @digest_regex)
    |> validate_number(:size_bytes, greater_than: 0)
    |> validate_number(:record_count, greater_than: 0)
    |> validate_length(:owner_ref, min: 1, max: 256)
    |> validate_length(:owner_description, min: 1, max: 2_000)
    |> validate_length(:source_ref, min: 1, max: 1_000)
    |> validate_length(:license_contract_ref, min: 1, max: 256)
    |> validate_length(:buyer_name, min: 1, max: 256)
    |> validate_length(:buyer_class, min: 1, max: 128)
    |> validate_length(:publication_receipt_ref, min: 1, max: 256)
    |> validate_safe_map(:provenance)
    |> validate_safe_map(:schema)
    |> validate_safe_map(:coverage)
    |> validate_safe_map(:redaction)
    |> validate_safe_map(:license_terms)
    |> validate_safe_map(:price)
    |> validate_safe_map(:verification_policy)
    |> validate_opt_in_license()
    |> validate_license_window()
    |> validate_active_license()
    |> put_identity_digests(attributes)
    |> unique_constraint([:artifact_digest, :provenance_digest, :license_digest],
      name: :verified_artifact_listings_identity_index
    )
    |> unique_constraint(:listing_digest)
    |> unique_constraint(:publication_receipt_ref)
  end

  def removal_changeset(listing, attributes) do
    listing
    |> change(attributes)
    |> validate_required([:state, :removed_at, :removal_reason])
    |> validate_inclusion(:state, ["removed"])
    |> validate_length(:removal_reason, min: 1, max: 1_000)
  end

  def public_projection(%__MODULE__{} = listing) do
    %{
      "id" => listing.id,
      "artifact_type" => listing.artifact_type,
      "owner" => %{
        "ref" => listing.owner_ref,
        "description" => listing.owner_description
      },
      "artifact_digest" => listing.artifact_digest,
      "provenance" => Map.put(listing.provenance, "digest", listing.provenance_digest),
      "schema" => listing.schema,
      "size" => %{
        "bytes" => listing.size_bytes,
        "records" => listing.record_count
      },
      "coverage" => listing.coverage,
      "redaction" => listing.redaction,
      "license" => %{
        "contract_ref" => listing.license_contract_ref,
        "digest" => listing.license_digest,
        "terms" => listing.license_terms,
        "effective_at" => listing.license_effective_at,
        "expires_at" => listing.license_expires_at
      },
      "price" => listing.price,
      "buyer" => %{
        "name" => listing.buyer_name,
        "class" => listing.buyer_class
      },
      "verification_policy" => listing.verification_policy,
      "evidence_fresh_at" => listing.evidence_fresh_at,
      "listing_digest" => listing.listing_digest,
      "publication_receipt_ref" => listing.publication_receipt_ref,
      "published_at" => listing.inserted_at
    }
  end

  defp validate_license_window(changeset) do
    effective_at = get_field(changeset, :license_effective_at)
    expires_at = get_field(changeset, :license_expires_at)
    evidence_fresh_at = get_field(changeset, :evidence_fresh_at)

    changeset
    |> validate_after(:license_expires_at, expires_at, :license_effective_at, effective_at)
    |> validate_after(:license_expires_at, expires_at, :evidence_fresh_at, evidence_fresh_at)
  end

  defp validate_opt_in_license(changeset) do
    case get_field(changeset, :license_terms) do
      %{"opt_in" => true} -> changeset
      %{opt_in: true} -> changeset
      _terms -> add_error(changeset, :license_terms, "must record explicit opt-in")
    end
  end

  defp validate_active_license(changeset) do
    now = DateTime.utc_now()
    effective_at = get_field(changeset, :license_effective_at)
    expires_at = get_field(changeset, :license_expires_at)

    changeset
    |> validate_not_after_now(:license_effective_at, effective_at, now)
    |> validate_after_now(:license_expires_at, expires_at, now)
  end

  defp validate_not_after_now(changeset, field, %DateTime{} = at, now) do
    if DateTime.compare(at, now) in [:lt, :eq] do
      changeset
    else
      add_error(changeset, field, "must be active")
    end
  end

  defp validate_not_after_now(changeset, _field, _at, _now), do: changeset

  defp validate_after_now(changeset, field, %DateTime{} = at, now) do
    if DateTime.compare(at, now) == :gt do
      changeset
    else
      add_error(changeset, field, "must be current")
    end
  end

  defp validate_after_now(changeset, _field, _at, _now), do: changeset

  defp validate_after(changeset, field, %DateTime{} = later, earlier_field, %DateTime{} = earlier) do
    if DateTime.compare(later, earlier) == :gt do
      changeset
    else
      add_error(changeset, field, "must be after #{earlier_field}")
    end
  end

  defp validate_after(changeset, _field, _later, _earlier_field, _earlier), do: changeset

  defp put_identity_digests(%Ecto.Changeset{valid?: false} = changeset, _attributes),
    do: changeset

  defp put_identity_digests(changeset, attributes) do
    listing = apply_changes(changeset)

    license_digest =
      Canonical.digest!(%{
        "contract_ref" => listing.license_contract_ref,
        "effective_at" => DateTime.to_iso8601(listing.license_effective_at),
        "expires_at" => DateTime.to_iso8601(listing.license_expires_at),
        "terms" => listing.license_terms
      })

    listing_digest =
      listing
      |> public_identity(license_digest)
      |> Canonical.digest!()

    changeset
    |> verify_supplied_digest(attributes, :license_digest, license_digest)
    |> verify_supplied_digest(attributes, :listing_digest, listing_digest)
    |> put_change(:license_digest, license_digest)
    |> put_change(:listing_digest, listing_digest)
  end

  defp public_identity(listing, license_digest) do
    %{
      "artifact_type" => listing.artifact_type,
      "owner" => %{
        "ref" => listing.owner_ref,
        "description" => listing.owner_description
      },
      "artifact_digest" => listing.artifact_digest,
      "provenance" => Map.put(listing.provenance, "digest", listing.provenance_digest),
      "schema" => listing.schema,
      "size" => %{"bytes" => listing.size_bytes, "records" => listing.record_count},
      "coverage" => listing.coverage,
      "redaction" => listing.redaction,
      "license" => %{
        "contract_ref" => listing.license_contract_ref,
        "digest" => license_digest,
        "terms" => listing.license_terms,
        "effective_at" => DateTime.to_iso8601(listing.license_effective_at),
        "expires_at" => DateTime.to_iso8601(listing.license_expires_at)
      },
      "price" => listing.price,
      "buyer" => %{"name" => listing.buyer_name, "class" => listing.buyer_class},
      "verification_policy" => listing.verification_policy,
      "evidence_fresh_at" => DateTime.to_iso8601(listing.evidence_fresh_at)
    }
  end

  defp verify_supplied_digest(changeset, attributes, field, expected) do
    case attribute(attributes, field) do
      nil -> changeset
      ^expected -> changeset
      _mismatch -> add_error(changeset, field, "does not match the canonical identity")
    end
  end

  defp attribute(attributes, field) do
    Map.get(attributes, field) || Map.get(attributes, Atom.to_string(field))
  end

  defp validate_safe_map(changeset, field) do
    validate_change(changeset, field, fn ^field, value ->
      cond do
        not is_map(value) -> [{field, "must be a map"}]
        contains_private_key?(value) -> [{field, "contains private source metadata"}]
        true -> []
      end
    end)
  end

  defp contains_private_key?(map) when is_map(map) do
    Enum.any?(map, fn {key, value} ->
      normalize_key(key) in @private_keys or contains_private_key?(value)
    end)
  end

  defp contains_private_key?(values) when is_list(values),
    do: Enum.any?(values, &contains_private_key?/1)

  defp contains_private_key?(_value), do: false

  defp normalize_key(key) when is_atom(key), do: Atom.to_string(key)
  defp normalize_key(key) when is_binary(key), do: key
  defp normalize_key(_key), do: ""
end