lib/openagents/issues/evidence.ex

main at 58e6347eeb72 · 23 KB

defmodule OpenAgents.Issues.Evidence do
  @moduledoc """
  Bind an issue to the receipts that evaluated the exact commit its work
  produced.

  The commit-to-receipt chain was already complete before this module existed,
  and already joined — by sha, never by issue. `OpenAgents.Forge.receipts_for/2`
  returns every push, build, target, and deploy receipt for a sha, and
  `changelog_entries` carries four receipt ids in one row. What none of them
  carried was an issue. This module adds that one edge and nothing else.

  ## Two directions that meet

  Evidence arrives in either order, and neither order is wrong:

    * a **receipt** lands for a commit that an issue already claims, and
    * an **attempt** reports the revision it produced for a commit whose
      receipts already exist.

  So there are two entry points. `record/1` is the receipt side: given a
  receipt, it finds the issues that claim its commit and appends one row for
  each. `bind_attempt/1` is the attempt side: given a finished attempt with a
  terminal commit, it sweeps the receipt tables for that exact sha and appends
  whatever already exists. Both are idempotent against the same unique index,
  so the two directions meeting in the middle writes one row, not two.

  Neither direction scans a window. `Forge.receipts_for/2` and the changelog's
  receipt index scan bounded windows and honestly return empty for an older
  commit; this module queries `{repo, sha}` and `{repository_id, commit_sha}`
  through indexes, so a commit's age never changes the answer.

  ## Which issues claim a commit

  Two sources, and no third. There is no second commit-to-issue extractor here:

    * `issue_closing_references` — the authoritative half. `#130` reads the
      trailer, verifies the pusher can write the issue, and requires the commit
      to be reachable from the default branch before recording anything.
      `OpenAgents.Forge.CommitReferences` is the one reader of commit prose in
      this application and stays that way.
    * `forge_assignments.terminal_commit` — the attempt's own self-report of
      the revision it produced. Weaker, because it is the executor's claim
      rather than a merge, and recorded as such in `source`.

  A commit claimed by both records once, attributed to the trailer, because the
  merge is the stronger fact.

  ## What it refuses

  A receipt is evidence for the commit and the environment it names, and for no
  other. `record/1` re-reads the receipt row and refuses a caller that names a
  different commit (`:evidence_commit_mismatch`) or a different environment
  (`:evidence_environment_mismatch`). A receipt id from one family looked up in
  another family's table is not found, which is why a push receipt can never be
  recorded as a deployment receipt.

  Nothing here deletes. A failed build, a reverted deployment, a cancelled
  attempt, and a superseded run all keep their edge, with the receipt's own
  terminal word in `result`. An issue's history is what happened, not what
  worked.
  """

  import Ecto.Query

  require Logger

  alias OpenAgents.Deployments.CheckResult
  alias OpenAgents.Deployments.Request, as: DeploymentRequest
  alias OpenAgents.Deployments.Run, as: DeploymentRun
  alias OpenAgents.Forge.{Assignment, BuildReceipt, DeployReceipt, PushReceipt}
  alias OpenAgents.Forge.ReceiptRepository
  alias OpenAgents.Issues.{ClosingReference, CompletionClaims, EvidenceEntry, Issue}
  alias OpenAgents.Repo
  alias OpenAgents.Repositories.Repository
  alias OpenAgents.Transparency.WorkDisclosure

  # See `OpenAgents.Forge.Assignments`: an internal caller reads the record,
  # not a projection of it. Every reader-facing surface passes a real viewer.
  @unclamped %{account_id: nil, tier: :glass, admin: true}

  # The forge release plane converges one fleet. Naming it rather than leaving
  # it blank is what lets the environment refusal mean something on both planes.
  @fleet "fleet"

  # No one commit carries more evidence than this. The cap bounds the work one
  # receipt can ask of the issue tracker when a commit closes many issues.
  @claimant_limit 64

  @doc """
  Append the evidence one receipt gives every issue that claims its commit.

  `attrs` names the `:family`, the `:receipt_id`, and the `:actor`. The commit,
  the repository, the plane, the environment, and the result are read from the
  receipt row itself, so a caller cannot relabel evidence by asserting them.
  Pass `:commit_sha` or `:environment` to assert what the receipt should say,
  and the call is refused when it says otherwise.

  Returns `{:ok, entries}` with the rows this call wrote, which is `[]` when no
  issue claims the commit and when every edge already existed.
  """
  @spec record(map()) :: {:ok, [EvidenceEntry.t()]} | {:error, atom()}
  def record(attrs) when is_map(attrs) do
    with {:ok, family} <- family(attrs),
         {:ok, receipt_id} <- receipt_id(attrs),
         {:ok, facts} <- receipt_facts(family, receipt_id),
         :ok <- assert_commit(facts, attrs[:commit_sha] || attrs["commit_sha"]),
         :ok <- assert_environment(facts, attrs[:environment] || attrs["environment"]) do
      {:ok, append(facts, actor(attrs))}
    end
  end

  @doc """
  Append the evidence that already exists for a finished attempt's revision.

  An attempt reports the exact commit it produced; that commit may already have
  been pushed, built, qualified, and deployed. This sweeps each receipt family
  by `{repo, sha}` and records what it finds, so an attempt that finishes after
  its receipts does not lose them.

  Never raises and never fails the attempt that called it. An assignment that
  finished is finished whether or not its evidence could be written.
  """
  @spec bind_attempt(Assignment.t()) :: [EvidenceEntry.t()]
  def bind_attempt(%Assignment{terminal_commit: commit} = assignment)
      when is_binary(commit) do
    case Repo.get(Repository, assignment.repository_id) do
      %Repository{} = repository -> sweep(repository, commit, assignment)
      nil -> []
    end
  rescue
    error ->
      Logger.warning(
        "issue_evidence_attempt_bind_failed code=#{OpenAgents.OperationalLog.code(error)}"
      )

      []
  end

  def bind_attempt(%Assignment{}), do: []

  @doc """
  The evidence recorded against one issue, oldest first.

  The issue is the requested outcome and never becomes a work record. This
  reads edges, so an issue with no evidence returns an empty list rather than
  an absent fact.
  """
  @spec for_issue(Issue.t() | integer(), map()) :: [map()]
  def for_issue(issue_id, viewer \\ @unclamped)

  def for_issue(%Issue{id: id}, viewer), do: for_issue(id, viewer)

  def for_issue(issue_id, viewer) when is_integer(issue_id) do
    EvidenceEntry
    |> where([entry], entry.issue_id == ^issue_id)
    |> order_by([entry], asc: entry.inserted_at, asc: entry.id)
    |> preload(:artifact_link)
    |> Repo.all()
    |> Enum.map(&summary(&1, viewer))
    |> Enum.reject(&is_nil/1)
  end

  @doc """
  Evidence for a whole page of issues, keyed by issue id.

  One query for the page, the way `OpenAgents.Forge.Assignments.attempts_for_issues/1`
  reads attempts, so listing issues does not cost one query per row. Every issue
  in `issues` appears in the result, with `[]` when it has no evidence.
  """
  @spec for_issues([Issue.t()], map()) :: %{integer() => [map()]}
  def for_issues(issues, viewer \\ @unclamped) when is_list(issues) do
    ids = Enum.map(issues, & &1.id)
    base = Map.new(ids, &{&1, []})

    EvidenceEntry
    |> where([entry], entry.issue_id in ^ids)
    |> order_by([entry], asc: entry.inserted_at, asc: entry.id)
    |> preload(:artifact_link)
    |> Repo.all()
    |> Enum.reduce(base, fn entry, acc ->
      case summary(entry, viewer) do
        nil -> acc
        projection -> Map.update(acc, entry.issue_id, [projection], &(&1 ++ [projection]))
      end
    end)
  end

  @doc """
  The bounded projection of one evidence edge, at the tier `viewer` is
  admitted to.

  `pulse` is the acceptance criterion "a public issue can say that restricted
  evidence exists": the family and the receipt's own verdict, without the
  revision, the receipt handle, or the environment those bytes reached.
  `ledger` adds those three. Which field sits on which rung is decided in
  `OpenAgents.Transparency.WorkDisclosure` and read from there, never restated
  here.

  Nothing about the execution appears at any rung. The work job's report, the
  attempt's prompt, and the credential are in that schedule's never list, not
  on a rung nobody has reached yet.
  """
  @spec summary(EvidenceEntry.t(), map()) :: map() | nil
  def summary(entry, viewer \\ @unclamped)

  def summary(%EvidenceEntry{} = entry, viewer) do
    WorkDisclosure.project(
      :evidence,
      %{
        id: entry.id,
        commit: entry.commit_sha,
        family: entry.family,
        receipt_id: entry.receipt_id,
        plane: entry.plane,
        environment: entry.environment,
        result: entry.result,
        source: entry.source,
        recorded_at: entry.inserted_at
      },
      WorkDisclosure.effective_tier(entry, viewer)
    )
  end

  # ── receipt-side entry points ───────────────────────────────────────────

  @doc "Record a completed or failed forge build receipt as evidence."
  @spec record_build(BuildReceipt.t()) :: [EvidenceEntry.t()]
  def record_build(%BuildReceipt{id: id}), do: soft_record(%{family: "build", receipt_id: id})

  @doc "Record an immutable forge deployment receipt as evidence."
  @spec record_deploy(DeployReceipt.t()) :: [EvidenceEntry.t()]
  def record_deploy(%DeployReceipt{id: id}),
    do: soft_record(%{family: "deployment", receipt_id: id})

  @doc "Record a published qualification receipt as evidence."
  @spec record_check_result(CheckResult.t()) :: [EvidenceEntry.t()]
  def record_check_result(%CheckResult{id: id}),
    do: soft_record(%{family: "qualification", receipt_id: id})

  @doc "Record a terminal tenant deployment run as evidence."
  @spec record_deployment_run(DeploymentRun.t()) :: [EvidenceEntry.t()]
  def record_deployment_run(%DeploymentRun{id: id}),
    do: soft_record(%{family: "deployment", receipt_id: id})

  @doc """
  Record a push receipt as evidence for one issue the push's commit closed.

  `OpenAgents.Issues.ClosingReferences` calls this while it records the
  reference, which is the one moment the issue, the commit, the repository, and
  the push receipt are all in hand. Reading them back later would need a window
  scan; writing them here does not.
  """
  @spec record_push(Repository.t(), Issue.t(), String.t(), binary() | nil, String.t()) ::
          [EvidenceEntry.t()]
  def record_push(%Repository{} = repository, %Issue{} = issue, commit_sha, receipt_id, actor)
      when is_binary(commit_sha) and is_binary(receipt_id) and is_binary(actor) do
    insert(%{
      repository_id: repository.id,
      issue_id: issue.id,
      commit_sha: commit_sha,
      family: "push",
      receipt_id: receipt_id,
      plane: "forge",
      environment: nil,
      result: nil,
      actor: actor,
      source: "closing_reference",
      assignment_id: nil
    })
  end

  # A push whose receipt row was lost between the WAL ack and the insert has no
  # receipt to point at. `reconcile_receipts/1` re-derives the receipt and
  # reaches here again with it.
  def record_push(_repository, _issue, _commit_sha, _receipt_id, _actor), do: []

  # ── internals ───────────────────────────────────────────────────────────

  defp soft_record(attrs) do
    case record(attrs) do
      {:ok, entries} ->
        entries

      # A receipt whose `repo` string names no tracked repository, and a
      # receipt id that is not in its family's table, are both ordinary: the
      # forge builds and deploys artifacts this tracker has no repository for.
      # Only a caller asserting the wrong commit or environment is worth a
      # warning, because that is a bug rather than a gap.
      {:error, reason}
      when reason in [:evidence_repository_not_resolved, :evidence_receipt_not_found] ->
        Logger.debug("issue_evidence_not_recorded reason=#{reason}")
        []

      {:error, reason} ->
        Logger.warning("issue_evidence_not_recorded reason=#{reason}")
        []
    end
  rescue
    error ->
      Logger.warning("issue_evidence_failed code=#{OpenAgents.OperationalLog.code(error)}")
      []
  end

  defp family(attrs) do
    case attrs[:family] || attrs["family"] do
      family when family in ["push", "build", "deployment", "qualification"] -> {:ok, family}
      _unnamed -> {:error, :evidence_family_unknown}
    end
  end

  defp receipt_id(attrs) do
    case Ecto.UUID.cast(attrs[:receipt_id] || attrs["receipt_id"]) do
      {:ok, id} -> {:ok, id}
      :error -> {:error, :evidence_receipt_not_found}
    end
  end

  defp actor(attrs), do: to_string(attrs[:actor] || attrs["actor"] || "system:forge")

  # Every family resolves to the same shape: the repository the receipt belongs
  # to, the exact commit it evaluated, the plane it lives in, the environment it
  # reached, and its own terminal word.
  defp receipt_facts("push", id) do
    case Repo.get(PushReceipt, id) do
      %PushReceipt{} ->
        # A push receipt carries refs rather than one commit, so it cannot be
        # resolved from the receipt alone. `record_push/5` writes it at the one
        # moment the commit is known.
        {:error, :evidence_push_needs_commit}

      nil ->
        {:error, :evidence_receipt_not_found}
    end
  end

  defp receipt_facts("build", id) do
    case Repo.get(BuildReceipt, id) do
      %BuildReceipt{} = receipt ->
        with %Repository{} = repository <- repository_for(receipt) do
          {:ok,
           %{
             repository: repository,
             commit_sha: receipt.sha,
             family: "build",
             receipt_id: receipt.id,
             plane: "forge",
             environment: nil,
             result: receipt.status
           }}
        else
          _unresolved -> {:error, :evidence_repository_not_resolved}
        end

      nil ->
        {:error, :evidence_receipt_not_found}
    end
  end

  defp receipt_facts("deployment", id) do
    case Repo.get(DeployReceipt, id) do
      %DeployReceipt{} = receipt -> forge_deployment_facts(receipt)
      nil -> tenant_deployment_facts(id)
    end
  end

  defp receipt_facts("qualification", id) do
    case Repo.get(CheckResult, id) do
      %CheckResult{} = result ->
        case Repo.get(Repository, result.repository_id) do
          %Repository{} = repository ->
            {:ok,
             %{
               repository: repository,
               commit_sha: result.commit_sha,
               family: "qualification",
               receipt_id: result.id,
               plane: "tenant",
               environment: nil,
               result: result.status
             }}

          nil ->
            {:error, :evidence_repository_not_resolved}
        end

      nil ->
        {:error, :evidence_receipt_not_found}
    end
  end

  defp forge_deployment_facts(%DeployReceipt{} = receipt) do
    case repository_for(receipt) do
      %Repository{} = repository ->
        {:ok,
         %{
           repository: repository,
           commit_sha: receipt.sha,
           family: "deployment",
           receipt_id: receipt.id,
           plane: "forge",
           environment: @fleet,
           result: receipt.result
         }}

      nil ->
        {:error, :evidence_repository_not_resolved}
    end
  end

  defp tenant_deployment_facts(id) do
    query =
      from run in DeploymentRun,
        join: request in DeploymentRequest,
        on: request.id == run.deployment_request_id,
        join: repository in Repository,
        on: repository.id == run.repository_id,
        left_join: environment in assoc(run, :environment),
        where: run.id == ^id,
        select: {run, request.commit_sha, repository, environment.name}

    case Repo.one(query) do
      {%DeploymentRun{} = run, commit_sha, %Repository{} = repository, environment} ->
        {:ok,
         %{
           repository: repository,
           commit_sha: commit_sha,
           family: "deployment",
           receipt_id: run.id,
           plane: "tenant",
           environment: environment,
           result: run.state
         }}

      _missing ->
        {:error, :evidence_receipt_not_found}
    end
  end

  defp assert_commit(_facts, nil), do: :ok

  defp assert_commit(%{commit_sha: actual}, asserted) when is_binary(asserted) do
    if String.downcase(asserted) == String.downcase(to_string(actual)),
      do: :ok,
      else: {:error, :evidence_commit_mismatch}
  end

  defp assert_commit(_facts, _asserted), do: {:error, :evidence_commit_mismatch}

  defp assert_environment(_facts, nil), do: :ok

  defp assert_environment(%{environment: actual}, asserted) when is_binary(asserted) do
    if asserted == actual, do: :ok, else: {:error, :evidence_environment_mismatch}
  end

  defp assert_environment(_facts, _asserted), do: {:error, :evidence_environment_mismatch}

  defp append(facts, actor) do
    facts.repository
    |> claimants(facts.commit_sha)
    |> Enum.flat_map(fn {issue_id, source, assignment_id} ->
      insert(%{
        repository_id: facts.repository.id,
        issue_id: issue_id,
        commit_sha: facts.commit_sha,
        family: facts.family,
        receipt_id: facts.receipt_id,
        plane: facts.plane,
        environment: facts.environment,
        result: facts.result,
        actor: actor,
        source: source,
        assignment_id: assignment_id
      })
    end)
  end

  # The two sources, in priority order. A commit claimed by a trailer and by an
  # attempt records once, as `closing_reference`, because a merge is a stronger
  # fact than an executor's self-report.
  defp claimants(%Repository{id: repository_id}, commit_sha) do
    referenced =
      Repo.all(
        from reference in ClosingReference,
          where:
            reference.repository_id == ^repository_id and
              reference.commit_sha == ^commit_sha,
          select: reference.issue_id,
          limit: @claimant_limit
      )
      |> Enum.map(&{&1, "closing_reference", nil})

    claimed = MapSet.new(referenced, fn {issue_id, _source, _assignment} -> issue_id end)

    reported =
      Repo.all(
        from assignment in Assignment,
          where:
            assignment.repository_id == ^repository_id and
              assignment.terminal_commit == ^commit_sha,
          select: {assignment.issue_id, assignment.id},
          limit: @claimant_limit
      )
      |> Enum.reject(fn {issue_id, _id} -> MapSet.member?(claimed, issue_id) end)
      |> Enum.uniq_by(fn {issue_id, _id} -> issue_id end)
      |> Enum.map(fn {issue_id, assignment_id} -> {issue_id, "assignment", assignment_id} end)

    Enum.take(referenced ++ reported, @claimant_limit)
  end

  # Replay is a no-op in two layers. The read answers the ordinary second
  # arrival — a reconciled receipt, a re-presented commit — without touching
  # the row it finds. `on_conflict: :nothing` answers the concurrent one,
  # because a constraint error raised inside `ClosingReferences`' transaction
  # would roll back the close it guards rather than skipping the edge.
  defp insert(attrs) do
    if recorded?(attrs) do
      []
    else
      %EvidenceEntry{}
      |> EvidenceEntry.changeset(inherit_link(attrs))
      |> Repo.insert(
        on_conflict: :nothing,
        conflict_target: [:issue_id, :commit_sha, :family, :receipt_id]
      )
      |> case do
        {:ok, %EvidenceEntry{} = entry} ->
          # A receipt that disagrees with an accepted claim's evidence marks
          # that claim contradicted. It never reopens the issue and never
          # fails the receipt that produced it; #150 owns that rule.
          _ = note_contradiction(entry)
          [entry]

        {:error, _changeset} ->
          []
      end
    end
  end

  # An edge an attempt produced consents on the attempt's link, not on one of
  # its own. The attempt is where a person chose to start work and where a
  # revocation has to bite: revoking the attempt's link must take its receipts
  # with it, and a second link per edge would leave them behind.
  defp inherit_link(%{assignment_id: id} = attrs) when is_binary(id) do
    case Repo.one(
           from a in Assignment,
             where: a.id == ^id,
             select: {a.artifact_link_id, a.transparency_tier}
         ) do
      {link_id, tier} when is_binary(tier) ->
        attrs |> Map.put(:artifact_link_id, link_id) |> Map.put(:transparency_tier, tier)

      _absent ->
        attrs
    end
  end

  defp inherit_link(attrs), do: attrs

  defp note_contradiction(%EvidenceEntry{} = entry) do
    CompletionClaims.note_evidence(entry)
  rescue
    error ->
      Logger.warning(
        "issue_evidence_contradiction_failed code=#{OpenAgents.OperationalLog.code(error)}"
      )

      :ok
  end

  defp recorded?(attrs) do
    Repo.exists?(
      from entry in EvidenceEntry,
        where:
          entry.issue_id == ^attrs.issue_id and
            entry.commit_sha == ^String.downcase(attrs.commit_sha) and
            entry.family == ^attrs.family and
            entry.receipt_id == ^attrs.receipt_id
    )
  end

  # A receipt written since #181 carries `repository_id`, so the evidence chain
  # no longer depends on a name resolving to exactly one repository. The string
  # is read only for a row the backfill could not settle, and there it keeps the
  # old refusal: two candidates record nothing rather than guessing which issue
  # the receipt belongs to.
  defp repository_for(%{repository_id: repository_id}) when is_binary(repository_id) do
    Repo.get(Repository, repository_id)
  end

  defp repository_for(%{repo: repo}), do: repository_by_name(repo)

  defp repository_by_name(repo) when is_binary(repo) do
    ReceiptRepository.resolve(repo)
  end

  defp repository_by_name(_repo), do: nil

  defp sweep(%Repository{} = repository, commit_sha, %Assignment{} = assignment) do
    # A receipt that names this repository is matched by its key. The string is
    # the fallback for a row the backfill could not settle, and restricting it
    # to this repository's own keys keeps a receipt for another repository's
    # identical sha out of the answer.
    keys = OpenAgents.Forge.Pushes.receipt_repo_keys(repository.storage_key)

    builds =
      BuildReceipt
      |> ReceiptRepository.scope(repository, keys)
      |> where([build], build.sha == ^commit_sha)
      |> select([build], build.id)
      |> limit(@claimant_limit)
      |> Repo.all()
      |> Enum.map(&%{family: "build", receipt_id: &1})

    deploys =
      DeployReceipt
      |> ReceiptRepository.scope(repository, keys)
      |> where([deploy], deploy.sha == ^commit_sha)
      |> select([deploy], deploy.id)
      |> limit(@claimant_limit)
      |> Repo.all()
      |> Enum.map(&%{family: "deployment", receipt_id: &1})

    checks =
      Repo.all(
        from result in CheckResult,
          where: result.repository_id == ^repository.id and result.commit_sha == ^commit_sha,
          select: result.id,
          limit: @claimant_limit
      )
      |> Enum.map(&%{family: "qualification", receipt_id: &1})

    actor = assignment_actor(assignment)

    (builds ++ deploys ++ checks)
    |> Enum.flat_map(&soft_record(Map.put(&1, :actor, actor)))
  end

  defp assignment_actor(%Assignment{requesting_principal: %{"type" => type, "id" => id}})
       when is_binary(type) and is_binary(id),
       do: "#{type}:#{id}"

  defp assignment_actor(%Assignment{}), do: "system:forge"
end