lib/openagents/reputation.ex

58e6347eeb72 · 38 KB

defmodule OpenAgents.Reputation do
  @moduledoc """
  Portable, revocable reputation attestations for accepted outcomes.

  An attestation is a signed claim that one subject completed, verified,
  reviewed, was paid for, or lost credit for one accepted outcome, under one
  admitted verifier policy, in one repository, at one revision. It is scoped
  evidence a stranger can check, never a global social score: nothing here
  derives credit from presence, token volume, online time, or narration, and
  no function returns a universal ranking.

  The context owns four operations:

    * `admit_policy/1` and `admit_key/1` record the verifier policy and the
      issuer public key an attestation binds to.
    * `issue/3` signs a claim, and only after the accepted-outcome contract it
      names reached an admitted terminal state.
    * `verify/2` recomputes the digest, checks the signature against the
      admitted key, and reports policy, binding, evidence, and revocation
      state. It trusts no column and no caller.
    * `revoke/4` and `correct/4` publish a linked invalidating event.

  A subject is a bare string inside the signed claim, so the context also
  owns the binding that resolves one to an account: `claim_subject/2`,
  `approve_subject_claim/1`, and `reject_subject_claim/1`. Only a `linked`
  claim resolves a subject, and `linked_subject_ids/1` is the one filter an
  account-scoped read may use.

  Reads project the stored claim verbatim, so a client can verify an
  attestation the forge serves without trusting the surface that displayed it.
  """

  import Ecto.Query

  alias OpenAgents.Compensation.OutcomeDecision
  alias OpenAgents.Forge.Visibility
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Provenance.Canonical
  alias OpenAgents.Repo
  alias OpenAgents.Repositories.Repository
  alias OpenAgents.Accounts.User
  alias OpenAgents.Forum.ActorLink
  alias OpenAgents.Reputation.{Attestation, Claim, PolicyReceipt, SigningKey, SubjectClaim}

  @policy_id "openagents.reputation.verifier.v1"
  @policy_version 1
  @policy_rules %{
    "unit" => "scoped_evidence",
    "signature_algorithm" => "ed25519",
    "event_types" => Attestation.event_types(),
    "accepted_outcome_kinds" => ["compensation_outcome_decision"],
    "accepted_terminal_state" => "accepted",
    "minimum_confidence_ppm" => 500_000,
    "evidence_max_age_seconds" => 7_776_000,
    "evidence_kinds" => ["outcome", "issue", "repository", "attestation"],
    "global_score" => false
  }

  @doc "The verifier policy identifier every attestation binds to."
  def policy_id, do: @policy_id

  @doc "The rules of the current verifier policy version."
  def policy_rules, do: @policy_rules

  @doc "The digest of one policy version's rules."
  @spec policy_digest(String.t(), pos_integer(), map()) :: String.t()
  def policy_digest(policy_id, version, rules) do
    Canonical.digest!(%{"policy_id" => policy_id, "version" => version, "rules" => rules})
  end

  @doc """
  Admits the current verifier policy version under operator authority.

  The receipt is append-only, and its digest is what a client compares a
  claim's `verifier.policy_digest` against.
  """
  @spec admit_policy(map()) :: {:ok, PolicyReceipt.t()} | {:error, term()}
  def admit_policy(operator) do
    with :ok <- validate_operator(operator) do
      %PolicyReceipt{}
      |> PolicyReceipt.changeset(%{
        policy_id: @policy_id,
        version: @policy_version,
        policy_digest: policy_digest(@policy_id, @policy_version, @policy_rules),
        rules: @policy_rules,
        actor_id: operator.actor_id,
        auth_method: operator.auth_method,
        approval_receipt_ref: operator.approval_receipt_ref
      })
      |> Repo.insert()
    end
  end

  @doc "The admitted policy receipt for one version, if any."
  @spec policy(String.t(), pos_integer()) :: PolicyReceipt.t() | nil
  def policy(policy_id \\ @policy_id, version \\ @policy_version),
    do: Repo.get_by(PolicyReceipt, policy_id: policy_id, version: version)

  @doc "Every admitted policy version, oldest first."
  @spec policies() :: [PolicyReceipt.t()]
  def policies,
    do:
      Repo.all(
        from receipt in PolicyReceipt, order_by: [asc: receipt.policy_id, asc: receipt.version]
      )

  @doc """
  The published form of one policy version: the rules a client hashes to
  reproduce `policy_digest` itself.
  """
  @spec policy_projection(PolicyReceipt.t()) :: map()
  def policy_projection(%PolicyReceipt{} = receipt) do
    %{
      "policy_id" => receipt.policy_id,
      "version" => receipt.version,
      "policy_digest" => receipt.policy_digest,
      "rules" => receipt.rules,
      "admitted_at" => receipt.inserted_at
    }
  end

  @doc """
  Admits an issuer public key.

  Only the public half is stored. The private key stays in runtime
  configuration, so the table a verifier reads can never mint a claim.
  """
  @spec admit_key(map()) :: {:ok, SigningKey.t()} | {:error, term()}
  def admit_key(attributes) do
    public_key = Map.fetch!(attributes, :public_key)

    %SigningKey{}
    |> SigningKey.changeset(%{
      key_id: Map.get(attributes, :key_id) || Claim.key_id(public_key),
      algorithm: Map.get(attributes, :algorithm, Claim.algorithm()),
      public_key: public_key,
      issuer: Map.fetch!(attributes, :issuer),
      activated_at: Map.get(attributes, :activated_at) || DateTime.utc_now(),
      retired_at: Map.get(attributes, :retired_at)
    })
    |> Repo.insert()
  end

  @doc """
  Retires an issuer key. Attestations it already signed keep verifying.

  That sentence is enforced, not assumed: `active_at?/2`'s window is half-open
  at `retired_at`, so a backdated retirement would flip every attestation at or
  after that instant to unverified while its signature stays valid. The
  changeset refuses a `retired_at` at or before the newest attestation the key
  signed, earlier than the key's `activated_at` when it signed nothing, or more
  than `SigningKey.max_future_skew_seconds/0` in the future. There is no
  override — an operator who wants to disown signed history gets the refusal
  and files a revocation instead (#191).
  """
  @spec retire_key(SigningKey.t(), DateTime.t()) :: {:ok, SigningKey.t()} | {:error, term()}
  def retire_key(%SigningKey{} = key, retired_at \\ DateTime.utc_now()) do
    key
    |> SigningKey.retire_changeset(retired_at, latest_attested_at: latest_attested_at(key))
    |> Repo.update()
  end

  defp latest_attested_at(%SigningKey{key_id: key_id}) do
    Repo.one(
      from attestation in Attestation,
        where: attestation.issuer_key_id == ^key_id,
        select: max(attestation.attested_at)
    )
  end

  @doc "Every admitted issuer key, for independent verification."
  @spec keys() :: [SigningKey.t()]
  def keys, do: Repo.all(from key in SigningKey, order_by: [asc: key.activated_at])

  @doc """
  Issues one attestation for an accepted outcome.

  `signer` carries the admitted `key_id` and the runtime-only `private_key`.
  Issuance fails when the outcome is missing or not accepted, when the key is
  unknown, retired, or does not match the admitted public key, when the
  confidence falls below the policy, when the requested transparency tier
  exceeds the repository's authority, or when the same issuer already
  attested this event for this subject and outcome.
  """
  @spec issue(PolicyReceipt.t(), map(), map()) :: {:ok, Attestation.t()} | {:error, term()}
  def issue(%PolicyReceipt{} = policy, signer, attributes) do
    with :ok <- validate_policy(policy),
         :ok <- validate_event_type(attributes[:event_type], attributes[:revokes_id]),
         :ok <- validate_confidence(policy, attributes[:confidence_ppm]),
         {:ok, repository} <- fetch_repository(attributes[:repository]),
         :ok <- validate_issue_number(repository, attributes[:issue_number]),
         :ok <- validate_tier(repository, attributes[:transparency_tier]),
         {:ok, evidence} <- validate_evidence(policy, repository, attributes[:evidence]),
         {:ok, outcome} <- resolve_outcome(policy, attributes[:outcome]),
         {:ok, key} <- fetch_signing_key(signer, attributes[:attested_at]) do
      persist(policy, key, signer, repository, outcome, evidence, attributes)
    end
  end

  @doc """
  Publishes a linked invalidating event for `attestation`.

  `event_type` is `reversal` for an outcome that was undone and `revocation`
  for a claim that should no longer count. The original row keeps its claim
  and signature; only its revocation fields are set, and only once.
  """
  @spec revoke(Attestation.t(), PolicyReceipt.t(), map(), map()) ::
          {:ok, %{revocation: Attestation.t(), attestation: Attestation.t()}} | {:error, term()}
  def revoke(%Attestation{} = attestation, %PolicyReceipt{} = policy, signer, attributes) do
    event_type = Map.get(attributes, :event_type, "revocation")
    reason_code = Map.get(attributes, :reason_code)

    with :ok <- validate_invalidating_event(event_type),
         :ok <- validate_reason_code(reason_code),
         :ok <- require_live(attestation) do
      Repo.transaction(fn ->
        case issue_invalidation(attestation, policy, signer, attributes, event_type) do
          {:ok, revocation} ->
            %{
              revocation: revocation,
              attestation: mark_revoked!(attestation, revocation, reason_code)
            }

          {:error, reason} ->
            Repo.rollback(reason)
        end
      end)
    end
  end

  @doc """
  Corrects `attestation`: revokes it and issues a replacement that names the
  revoked claim digest in `supersedes`.
  """
  @spec correct(Attestation.t(), PolicyReceipt.t(), map(), map()) ::
          {:ok, %{revocation: Attestation.t(), correction: Attestation.t()}} | {:error, term()}
  def correct(%Attestation{} = attestation, %PolicyReceipt{} = policy, signer, attributes) do
    reason_code = Map.get(attributes, :reason_code, "corrected")

    Repo.transaction(fn ->
      with {:ok, revoked} <-
             revoke(attestation, policy, signer, %{
               event_type: "revocation",
               reason_code: reason_code,
               subject_id: attestation.subject_id
             }),
           {:ok, correction} <-
             issue(
               policy,
               signer,
               attributes
               |> Map.put(:supersedes_digest, attestation.claim_digest)
               |> Map.put_new(:evidence, evidence_for_link(attestation))
             ) do
        %{revocation: revoked.revocation, correction: correction}
      else
        {:error, reason} -> Repo.rollback(reason)
      end
    end)
  end

  @doc """
  Verifies one attestation the way a skeptical client does: recompute the
  claim digest, check the Ed25519 signature against the admitted public key,
  compare the policy and the binding, resolve the evidence, and read the
  revocation state.

  `expectation` is what the caller believes it is looking at — any of
  `:repository`, `:issue_number`, `:subject_id`, `:revision`, `:event_type`,
  `:outcome_ref`, or `:policy_id`. A mismatch is reported, which is what stops
  a valid attestation from being replayed for another issue, revision,
  verifier, or actor.
  """
  @spec verify(Attestation.t() | String.t(), map()) :: map()
  def verify(attestation, expectation \\ %{})

  def verify(claim_digest, expectation) when is_binary(claim_digest) do
    case Repo.get_by(Attestation, claim_digest: claim_digest) do
      nil ->
        %{"claim_digest" => claim_digest, "verified" => false, "reasons" => ["unknown_claim"]}

      attestation ->
        verify(attestation, expectation)
    end
  end

  def verify(%Attestation{} = attestation, expectation) do
    attestation = Repo.preload(attestation, :repository)
    key = Repo.get_by(SigningKey, key_id: attestation.issuer_key_id)
    digest_match? = Canonical.digest!(attestation.claim) == attestation.claim_digest
    signature = signature_report(attestation, key, digest_match?)
    policy = policy_report(attestation)
    binding = binding_report(attestation, expectation)
    evidence = evidence_report(attestation)
    revocation = revocation_report(attestation)

    report = %{
      "attestation_id" => attestation.id,
      "claim_digest" => attestation.claim_digest,
      "digest_match" => digest_match?,
      "signature" => signature,
      "policy" => policy,
      "binding" => binding,
      "evidence" => evidence,
      "revocation" => revocation
    }

    Map.put(report, "verified", verified?(report))
  end

  @doc """
  The published form of one attestation: the exact signed claim, its
  signature, and the state a verifier needs. The claim is the stored object,
  never a rendering of it.
  """
  @spec projection(Attestation.t()) :: map()
  def projection(%Attestation{} = attestation) do
    %{
      "id" => attestation.id,
      "claim" => attestation.claim,
      "claim_digest" => attestation.claim_digest,
      "signature" => attestation.signature,
      "signature_algorithm" => attestation.signature_algorithm,
      "event_type" => attestation.event_type,
      "subject_id" => attestation.subject_id,
      "issuer_key_id" => attestation.issuer_key_id,
      "transparency_tier" => attestation.transparency_tier,
      "attested_at" => attestation.attested_at,
      "supersedes" => attestation.supersedes_digest,
      "revokes" => attestation.revokes_id,
      "revocation" => %{
        "revoked" => not is_nil(attestation.revoked_at),
        "revoked_at" => attestation.revoked_at,
        "reason_code" => attestation.revocation_reason_code
      }
    }
  end

  @doc "The published form of one admitted key."
  @spec key_projection(SigningKey.t()) :: map()
  def key_projection(%SigningKey{} = key) do
    %{
      "key_id" => key.key_id,
      "algorithm" => key.algorithm,
      "public_key" => key.public_key,
      "issuer" => key.issuer,
      "activated_at" => key.activated_at,
      "retired_at" => key.retired_at,
      "status" => if(is_nil(key.retired_at), do: "active", else: "retired")
    }
  end

  @doc """
  The attestations on one issue that `tiers` may disclose.

  A `repository` tier attestation discloses evidence references to repository
  members, so callers pass the tiers the reader holds authority for.
  """
  @spec list_for_issue(Repository.t(), pos_integer(), [String.t()]) :: [Attestation.t()]
  def list_for_issue(%Repository{id: repository_id}, issue_number, tiers) do
    Repo.all(
      from attestation in Attestation,
        where:
          attestation.repository_id == ^repository_id and
            attestation.issue_number == ^issue_number and
            attestation.transparency_tier in ^tiers,
        order_by: [asc: attestation.attested_at, asc: attestation.id]
    )
  end

  @doc "One attestation in one repository, or `nil`."
  @spec get(Repository.t(), String.t(), [String.t()]) :: Attestation.t() | nil
  def get(%Repository{id: repository_id}, id, tiers) do
    Repo.one(
      from attestation in Attestation,
        where:
          attestation.repository_id == ^repository_id and attestation.id == ^id and
            attestation.transparency_tier in ^tiers
    )
  rescue
    Ecto.Query.CastError -> nil
  end

  @doc """
  Scoped evidence about one subject in one repository.

  The projection counts live and revoked events per policy inside one
  repository. `score` is always `nil`: a ranking system may weigh these
  counts, but nothing here publishes a universal number, and evidence from
  one repository never leaks into another's summary.
  """
  @spec subject_evidence(String.t(), Repository.t()) :: map()
  def subject_evidence(subject_id, %Repository{} = repository) do
    attestations =
      Repo.all(
        from attestation in Attestation,
          where:
            attestation.repository_id == ^repository.id and
              attestation.subject_id == ^subject_id
      )

    {live, revoked} = Enum.split_with(attestations, &is_nil(&1.revoked_at))

    %{
      "subject_id" => subject_id,
      "scope" => "repository",
      "repository" => path(repository),
      "policy_id" => @policy_id,
      "counts" => Enum.frequencies_by(live, & &1.event_type),
      "revoked" => length(revoked),
      "score" => nil
    }
  end

  ## ── the subject binding ────────────────────────────────────────────────

  @doc """
  Records an account's claim on an attestation subject.

  The claim starts `pending`: nothing has established that the subject is this
  account's, so nothing resolves yet. `attributes` carries `:subject_kind`,
  `:subject_id`, and for the two kinds that name another namespace, the row
  that already established the identity — `:forum_actor_link_id` for a legacy
  forum actor, `:agent_id` for an agent.

  The cross-namespace checks live here because a `CHECK` constraint cannot read
  another table: a `forum_actor` claim must name a `linked` `forum_actor_links`
  row belonging to this account whose `actor_ref` is the subject, and an
  `agent` claim must name a `linked` `agent_user_links` row for this account.
  The shape checks — which kind admits which reference, and that an `account`
  subject is this account's own actor reference — are constraints on the table.
  """
  @spec claim_subject(User.t(), map()) :: {:ok, SubjectClaim.t()} | {:error, term()}
  def claim_subject(%User{} = user, attributes) do
    attributes = Map.new(attributes, fn {key, value} -> {to_string(key), value} end)

    with :ok <- validate_subject_reference(user, attributes) do
      %SubjectClaim{}
      |> SubjectClaim.changeset(
        Map.merge(attributes, %{
          "user_id" => user.id,
          "status" => "pending",
          "proof_evidence" => %{"started_at" => DateTime.to_iso8601(DateTime.utc_now())}
        })
      )
      |> Repo.insert()
    end
  end

  @doc "Approves a pending subject claim after its proof has been checked."
  @spec approve_subject_claim(SubjectClaim.t()) :: {:ok, SubjectClaim.t()} | {:error, term()}
  def approve_subject_claim(%SubjectClaim{status: "pending"} = claim) do
    now = DateTime.utc_now()

    claim
    |> SubjectClaim.changeset(%{
      status: "linked",
      linked_at: now,
      proof_evidence:
        Map.put(claim.proof_evidence || %{}, "approved_at", DateTime.to_iso8601(now))
    })
    |> Repo.update()
  end

  def approve_subject_claim(%SubjectClaim{}), do: {:error, :not_pending}

  @doc "Rejects a pending subject claim."
  @spec reject_subject_claim(SubjectClaim.t()) :: {:ok, SubjectClaim.t()} | {:error, term()}
  def reject_subject_claim(%SubjectClaim{status: "pending"} = claim) do
    claim
    |> SubjectClaim.changeset(%{status: "rejected", rejected_at: DateTime.utc_now()})
    |> Repo.update()
  end

  def reject_subject_claim(%SubjectClaim{}), do: {:error, :not_pending}

  @doc "Every subject claim this account made, newest first."
  @spec list_subject_claims(User.t()) :: [SubjectClaim.t()]
  def list_subject_claims(%User{id: user_id}) do
    Repo.all(
      from claim in SubjectClaim,
        where: claim.user_id == ^user_id,
        order_by: [desc: claim.inserted_at, desc: claim.id]
    )
  end

  @doc "Every subject claim still waiting on an operator, oldest first."
  @spec list_pending_subject_claims() :: [SubjectClaim.t()]
  def list_pending_subject_claims do
    Repo.all(
      from claim in SubjectClaim,
        where: claim.status == "pending",
        order_by: [asc: claim.inserted_at, asc: claim.id]
    )
  end

  @doc "The subject claim behind `id`, or `{:error, :not_found}`."
  @spec fetch_subject_claim(String.t()) :: {:ok, SubjectClaim.t()} | {:error, :not_found}
  def fetch_subject_claim(id) do
    case Ecto.UUID.cast(id) do
      {:ok, uuid} ->
        case Repo.get(SubjectClaim, uuid) do
          %SubjectClaim{} = claim -> {:ok, claim}
          nil -> {:error, :not_found}
        end

      :error ->
        {:error, :not_found}
    end
  end

  @doc """
  The subject strings this account has established.

  Only a `linked` claim resolves a subject, so a pending or rejected claim
  widens nothing. An account with no linked claim gets `[]`, and a read
  filtered on `[]` returns nothing rather than everything.
  """
  @spec linked_subject_ids(User.t()) :: [String.t()]
  def linked_subject_ids(%User{id: user_id}) do
    Repo.all(
      from claim in SubjectClaim,
        where: claim.user_id == ^user_id and claim.status == "linked",
        select: claim.subject_id,
        order_by: [asc: claim.subject_id]
    )
  end

  @doc "The public projection of one subject claim."
  @spec subject_claim_projection(SubjectClaim.t()) :: map()
  def subject_claim_projection(%SubjectClaim{} = claim) do
    %{
      "id" => claim.id,
      "subject_kind" => claim.subject_kind,
      "subject_id" => claim.subject_id,
      "status" => claim.status,
      "proof_method" => claim.proof_method,
      "forum_actor_link_id" => claim.forum_actor_link_id,
      "agent_id" => claim.agent_id,
      "claimed_at" => iso8601(claim.inserted_at),
      "linked_at" => iso8601(claim.linked_at),
      "rejected_at" => iso8601(claim.rejected_at)
    }
  end

  defp iso8601(nil), do: nil
  defp iso8601(%DateTime{} = at), do: DateTime.to_iso8601(at)

  # An `account` subject is checked by the table: the string has to be this
  # account's own actor reference. The other two kinds name a row in another
  # namespace, and only a link that namespace already established counts.
  defp validate_subject_reference(_user, %{"subject_kind" => "account"}), do: :ok

  defp validate_subject_reference(user, %{"subject_kind" => "forum_actor"} = attributes) do
    link =
      Repo.get_by(ActorLink,
        id: cast_uuid(attributes["forum_actor_link_id"]),
        user_id: user.id,
        status: "linked"
      )

    cond do
      is_nil(link) -> {:error, :forum_actor_not_linked}
      link.actor_ref != attributes["subject_id"] -> {:error, :subject_is_not_the_actor_ref}
      true -> :ok
    end
  end

  defp validate_subject_reference(user, %{"subject_kind" => "agent"} = attributes) do
    linked? =
      Repo.exists?(
        from link in OpenAgents.Agents.AgentUserLink,
          where:
            link.agent_id == ^cast_uuid(attributes["agent_id"]) and
              link.user_id == ^user.id and link.status == "linked"
      )

    if linked?, do: :ok, else: {:error, :agent_not_linked}
  end

  defp validate_subject_reference(_user, _attributes), do: {:error, :unsupported_subject_kind}

  defp cast_uuid(value) do
    case Ecto.UUID.cast(value) do
      {:ok, uuid} -> uuid
      :error -> Ecto.UUID.generate()
    end
  end

  defp persist(policy, key, signer, repository, outcome, evidence, attributes) do
    attested_at = attributes[:attested_at] || DateTime.utc_now()

    claim =
      Claim.build(%{
        event_type: attributes[:event_type],
        issuer_key_id: key.key_id,
        issuer_public_key: key.public_key,
        subject_id: attributes[:subject_id],
        outcome_kind: outcome.kind,
        outcome_ref: outcome.ref,
        outcome_digest: outcome.digest,
        outcome_state: outcome.state,
        repository: path(repository),
        repository_id: repository.id,
        issue_number: attributes[:issue_number],
        revision: attributes[:revision],
        artifact_digest: attributes[:artifact_digest],
        policy_id: policy.policy_id,
        policy_version: policy.version,
        policy_digest: policy.policy_digest,
        confidence_ppm: attributes[:confidence_ppm],
        transparency_tier: attributes[:transparency_tier],
        evidence: evidence,
        attested_at: attested_at,
        nonce: attributes[:nonce] || Claim.nonce(),
        supersedes_digest: attributes[:supersedes_digest]
      })

    with {:ok, digest} <- Claim.digest(claim),
         {:ok, signature} <- Claim.sign(claim, Map.fetch!(signer, :private_key)) do
      %Attestation{}
      |> Attestation.changeset(%{
        repository_id: repository.id,
        issue_number: attributes[:issue_number],
        event_type: attributes[:event_type],
        subject_id: attributes[:subject_id],
        issuer_key_id: key.key_id,
        outcome_kind: outcome.kind,
        outcome_ref: outcome.ref,
        outcome_digest: outcome.digest,
        revision: attributes[:revision],
        artifact_digest: attributes[:artifact_digest],
        policy_id: policy.policy_id,
        policy_version: policy.version,
        policy_digest: policy.policy_digest,
        confidence_ppm: attributes[:confidence_ppm],
        transparency_tier: attributes[:transparency_tier],
        attested_at: attested_at,
        nonce: claim["nonce"],
        claim: claim,
        claim_digest: digest,
        signature: signature,
        signature_algorithm: Claim.algorithm(),
        supersedes_digest: attributes[:supersedes_digest],
        revokes_id: attributes[:revokes_id]
      })
      |> Repo.insert()
    end
  end

  defp issue_invalidation(attestation, policy, signer, attributes, event_type) do
    attestation = Repo.preload(attestation, :repository)

    issue(
      policy,
      signer,
      %{
        event_type: event_type,
        subject_id: Map.get(attributes, :subject_id, attestation.subject_id),
        outcome: %{kind: attestation.outcome_kind, ref: attestation.outcome_ref},
        repository: attestation.repository,
        issue_number: attestation.issue_number,
        revision: attestation.revision,
        artifact_digest: attestation.artifact_digest,
        confidence_ppm: Map.get(attributes, :confidence_ppm, 1_000_000),
        transparency_tier: attestation.transparency_tier,
        evidence: Map.get(attributes, :evidence) || evidence_for_link(attestation),
        supersedes_digest: attestation.claim_digest,
        revokes_id: attestation.id
      }
    )
  end

  defp mark_revoked!(attestation, revocation, reason_code) do
    attestation
    |> Attestation.revocation_changeset(%{
      revoked_at: revocation.attested_at,
      revocation_reason_code: reason_code,
      revoked_by_id: revocation.id
    })
    |> Repo.update!()
  end

  defp evidence_for_link(%Attestation{} = attestation) do
    [
      %{
        "kind" => "attestation",
        "ref" => attestation.claim_digest,
        "digest" => attestation.claim_digest,
        "observed_at" => DateTime.to_iso8601(attestation.attested_at)
      }
    ]
  end

  defp signature_report(attestation, nil, _digest_match?) do
    %{"valid" => false, "key_id" => attestation.issuer_key_id, "key_status" => "unknown"}
  end

  defp signature_report(attestation, %SigningKey{} = key, digest_match?) do
    valid? =
      digest_match? and
        attestation.signature_algorithm == key.algorithm and
        Claim.valid_signature?(attestation.claim, attestation.signature, key.public_key)

    %{
      "valid" => valid?,
      "key_id" => key.key_id,
      "key_status" => if(is_nil(key.retired_at), do: "active", else: "retired"),
      "key_active_at_attestation" => SigningKey.active_at?(key, attestation.attested_at)
    }
  end

  defp policy_report(attestation) do
    admitted =
      Repo.get_by(PolicyReceipt,
        policy_id: attestation.policy_id,
        version: attestation.policy_version
      )

    current = current_policy_version(attestation.policy_id)

    %{
      "policy_id" => attestation.policy_id,
      "version" => attestation.policy_version,
      "current_version" => current,
      "admitted" => not is_nil(admitted),
      "digest_match" =>
        not is_nil(admitted) and admitted.policy_digest == attestation.policy_digest,
      "superseded" => not is_nil(current) and current > attestation.policy_version
    }
  end

  defp current_policy_version(policy_id) do
    Repo.one(
      from receipt in PolicyReceipt,
        where: receipt.policy_id == ^policy_id,
        select: max(receipt.version)
    )
  end

  defp binding_report(attestation, expectation) do
    claimed = %{
      repository: attestation.claim["scope"]["repository"],
      issue_number: attestation.claim["scope"]["issue_number"],
      revision: attestation.claim["scope"]["revision"],
      subject_id: attestation.claim["subject"]["actor_id"],
      event_type: attestation.claim["event_type"],
      outcome_ref: attestation.claim["outcome"]["ref"],
      policy_id: attestation.claim["verifier"]["policy_id"]
    }

    mismatches =
      expectation
      |> Enum.filter(fn {field, expected} -> Map.get(claimed, field) != expected end)
      |> Enum.map(fn {field, expected} ->
        %{
          "field" => to_string(field),
          "expected" => expected,
          "claimed" => Map.get(claimed, field)
        }
      end)

    columns_match? =
      claimed.repository == path(attestation.repository) and
        claimed.issue_number == attestation.issue_number and
        claimed.subject_id == attestation.subject_id and
        claimed.event_type == attestation.event_type and
        claimed.revision == attestation.revision

    %{
      "matches" => mismatches == [] and columns_match?,
      "claim_matches_columns" => columns_match?,
      "mismatches" => mismatches
    }
  end

  defp evidence_report(attestation) do
    max_age = policy_rule(attestation, "evidence_max_age_seconds")

    entries =
      Enum.map(attestation.claim["evidence"] || [], fn entry ->
        age = evidence_age(entry, attestation.attested_at)

        Map.merge(entry, %{
          "available" => evidence_available?(entry, attestation),
          "age_seconds" => age,
          "stale" => is_integer(age) and is_integer(max_age) and age > max_age
        })
      end)

    %{
      "entries" => entries,
      "available" => entries != [] and Enum.all?(entries, & &1["available"]),
      "stale" => Enum.any?(entries, & &1["stale"])
    }
  end

  defp policy_rule(attestation, rule) do
    case Repo.get_by(PolicyReceipt,
           policy_id: attestation.policy_id,
           version: attestation.policy_version
         ) do
      nil -> Map.get(@policy_rules, rule)
      receipt -> Map.get(receipt.rules, rule)
    end
  end

  defp evidence_age(entry, attested_at) do
    with observed when is_binary(observed) <- entry["observed_at"],
         {:ok, observed_at, _offset} <- DateTime.from_iso8601(observed) do
      DateTime.diff(attested_at, observed_at)
    else
      _other -> nil
    end
  end

  defp evidence_available?(%{"disclosed" => false}, _attestation), do: false

  defp evidence_available?(entry, attestation) do
    case entry["kind"] do
      "outcome" ->
        resolvable_outcome?(attestation.outcome_kind, entry["ref"])

      "issue" ->
        issue_exists?(attestation.repository_id, entry["ref"])

      "repository" ->
        entry["ref"] == path(attestation.repository)

      "attestation" ->
        digest = entry["ref"]
        Repo.exists?(from other in Attestation, where: other.claim_digest == ^digest)

      _other ->
        false
    end
  end

  defp resolvable_outcome?("compensation_outcome_decision", ref) when is_binary(ref),
    do:
      Repo.exists?(from decision in OutcomeDecision, where: decision.decision_receipt_ref == ^ref)

  defp resolvable_outcome?(_kind, _ref), do: false

  defp issue_exists?(repository_id, ref) when is_binary(ref) do
    case Integer.parse(ref |> String.split("#") |> List.last() || "") do
      {number, ""} ->
        Repo.exists?(
          from issue in Issue,
            where: issue.repository_id == ^repository_id and issue.number == ^number
        )

      _other ->
        false
    end
  end

  defp issue_exists?(_repository_id, _ref), do: false

  defp revocation_report(attestation) do
    %{
      "revoked" => not is_nil(attestation.revoked_at),
      "revoked_at" => attestation.revoked_at,
      "reason_code" => attestation.revocation_reason_code,
      "revoked_by" => attestation.revoked_by_id,
      "supersedes" => attestation.supersedes_digest
    }
  end

  defp verified?(report) do
    private? = report["evidence"]["entries"] |> Enum.any?(&(&1["disclosed"] == false))

    report["digest_match"] and report["signature"]["valid"] and
      report["signature"]["key_active_at_attestation"] == true and
      report["policy"]["digest_match"] and report["binding"]["matches"] and
      not report["revocation"]["revoked"] and not report["evidence"]["stale"] and
      (report["evidence"]["available"] or private?)
  end

  defp validate_policy(%PolicyReceipt{} = policy) do
    expected = policy_digest(policy.policy_id, policy.version, policy.rules)

    if expected == policy.policy_digest, do: :ok, else: {:error, :policy_digest_mismatch}
  end

  # An invalidating event exists only as the linked successor of the claim it
  # invalidates, so it carries the attestation it revokes.
  defp validate_event_type(event_type, revokes_id) do
    invalidating? = event_type in Attestation.invalidating_event_types()

    cond do
      event_type not in Attestation.event_types() -> {:error, :event_type_unsupported}
      invalidating? and is_nil(revokes_id) -> {:error, :invalidation_requires_prior_attestation}
      not invalidating? and not is_nil(revokes_id) -> {:error, :event_type_not_invalidating}
      true -> :ok
    end
  end

  defp validate_invalidating_event(event_type) do
    if event_type in Attestation.invalidating_event_types(),
      do: :ok,
      else: {:error, :event_type_not_invalidating}
  end

  defp validate_reason_code(code) when is_binary(code) and byte_size(code) > 0, do: :ok
  defp validate_reason_code(_code), do: {:error, :reason_code_required}

  defp validate_confidence(policy, confidence) when is_integer(confidence) do
    minimum = Map.get(policy.rules, "minimum_confidence_ppm", 0)

    cond do
      confidence < 0 or confidence > 1_000_000 -> {:error, :confidence_out_of_range}
      confidence < minimum -> {:error, :confidence_below_policy}
      true -> :ok
    end
  end

  defp validate_confidence(_policy, _confidence), do: {:error, :confidence_required}

  defp fetch_repository(%Repository{} = repository), do: {:ok, repository}
  defp fetch_repository(_other), do: {:error, :repository_required}

  defp validate_issue_number(repository, number) when is_integer(number) and number > 0 do
    if Repo.exists?(
         from issue in Issue,
           where: issue.repository_id == ^repository.id and issue.number == ^number
       ),
       do: :ok,
       else: {:error, :issue_not_found}
  end

  defp validate_issue_number(_repository, _number), do: {:error, :issue_number_required}

  defp validate_tier(repository, tier) do
    cond do
      tier not in Attestation.transparency_tiers() ->
        {:error, :transparency_tier_unsupported}

      tier == "public" and not public_disclosure?(repository) ->
        {:error, :transparency_tier_exceeds_repository_authority}

      true ->
        :ok
    end
  end

  defp public_disclosure?(repository) do
    repository.visibility == "public" or Visibility.allows?(repository.name, :ledger)
  end

  defp validate_evidence(policy, repository, entries) when is_list(entries) and entries != [] do
    kinds = Map.get(policy.rules, "evidence_kinds", [])

    entries
    |> Enum.reduce_while({:ok, []}, fn entry, {:ok, validated} ->
      normalized = Map.new(entry, fn {key, value} -> {to_string(key), value} end)

      case validate_evidence_entry(normalized, kinds, repository) do
        :ok -> {:cont, {:ok, [normalized | validated]}}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
    |> case do
      {:ok, validated} -> {:ok, Enum.reverse(validated)}
      error -> error
    end
  end

  defp validate_evidence(_policy, _repository, _entries), do: {:error, :evidence_required}

  defp validate_evidence_entry(entry, kinds, repository) do
    cond do
      entry["kind"] not in kinds ->
        {:error, :evidence_kind_unsupported}

      not is_binary(entry["ref"]) or entry["ref"] == "" ->
        {:error, :evidence_ref_required}

      not valid_digest?(entry["digest"]) ->
        {:error, :evidence_digest_invalid}

      not valid_timestamp?(entry["observed_at"]) ->
        {:error, :evidence_observed_at_invalid}

      entry["kind"] in ~w(issue repository) and not repository_scoped?(entry["ref"], repository) ->
        {:error, :evidence_outside_repository_authority}

      true ->
        :ok
    end
  end

  defp repository_scoped?(ref, repository) do
    path = path(repository)

    ref == path or String.starts_with?(ref, path <> "#")
  end

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

  defp valid_digest?(_digest), do: false

  defp valid_timestamp?(value) when is_binary(value) do
    match?({:ok, _instant, _offset}, DateTime.from_iso8601(value))
  end

  defp valid_timestamp?(_value), do: false

  # The accepted-outcome contract. `compensation_outcome_decision` is the
  # accepted-outcome receipt the application records today; verified bounty
  # settlement adds one clause here and no new attestation semantics.
  defp resolve_outcome(policy, %{kind: kind, ref: ref}) when is_binary(kind) and is_binary(ref) do
    if kind in Map.get(policy.rules, "accepted_outcome_kinds", []) do
      resolve_outcome_state(policy, kind, ref)
    else
      {:error, :outcome_kind_unsupported}
    end
  end

  defp resolve_outcome(_policy, _outcome), do: {:error, :outcome_required}

  defp resolve_outcome_state(policy, "compensation_outcome_decision" = kind, ref) do
    terminal = Map.get(policy.rules, "accepted_terminal_state")

    case Repo.get_by(OutcomeDecision, decision_receipt_ref: ref) do
      nil ->
        {:error, :outcome_not_found}

      %OutcomeDecision{decision: ^terminal} = decision ->
        {:ok, %{kind: kind, ref: ref, digest: decision.outcome_digest, state: decision.decision}}

      %OutcomeDecision{} ->
        {:error, :outcome_not_accepted}
    end
  end

  defp fetch_signing_key(signer, attested_at) do
    instant = attested_at || DateTime.utc_now()
    private_key = Map.get(signer, :private_key)

    with {:ok, key} <- lookup_key(Map.get(signer, :key_id)),
         :ok <- require_active_key(key, instant),
         :ok <- require_matching_key(key, private_key) do
      {:ok, key}
    end
  end

  defp lookup_key(key_id) when is_binary(key_id) do
    case Repo.get_by(SigningKey, key_id: key_id) do
      nil -> {:error, :signing_key_unknown}
      key -> {:ok, key}
    end
  end

  defp lookup_key(_key_id), do: {:error, :signing_key_required}

  defp require_active_key(key, instant) do
    if SigningKey.active_at?(key, instant), do: :ok, else: {:error, :signing_key_retired}
  end

  defp require_matching_key(key, private_key) when is_binary(private_key) do
    if Claim.public_key_for(private_key) == key.public_key,
      do: :ok,
      else: {:error, :signing_key_mismatch}
  end

  defp require_matching_key(_key, _private_key), do: {:error, :private_key_required}

  defp require_live(%Attestation{revoked_at: nil}), do: :ok
  defp require_live(%Attestation{}), do: {:error, :already_revoked}

  defp validate_operator(%{authenticated: true} = operator) do
    required = [:actor_id, :auth_method, :approval_receipt_ref]

    if Enum.all?(required, &is_binary(Map.get(operator, &1))),
      do: :ok,
      else: {:error, :operator_receipt_incomplete}
  end

  defp validate_operator(_operator), do: {:error, :operator_unauthenticated}

  defp path(%Repository{owner: owner, name: name}), do: "#{owner}/#{name}"
end