lib/openagents/issues/completion_claim.ex

58e6347eeb72 · 4 KB

defmodule OpenAgents.Issues.CompletionClaim do
  @moduledoc """
  One graded completion claim (`issue_completion_claims`).

  The row is the durable half of `OpenAgents.AcceptedOutcome.evaluate/1`, which
  was a pure function with nothing behind it. It stores the verdict, the typed
  reasons, and the criterion-to-evidence mapping — never the prompt, the
  transcript, the report, or the budget those came from. The execution stays in
  `work_jobs`, the attempt stays in `forge_assignments`, the receipts stay in
  the tables their families own, and the edge between issue and receipt stays
  in `issue_evidence`. This row adds the one fact none of them carried: what
  was claimed about the issue's intent, and how that claim graded.

  `{issue_id, assignment_id, revision}` is unique. An attempt reporting one
  revision has one verdict, so resubmitting a claim regrades it rather than
  accumulating verdicts nobody can order.

  Two columns carry the close, and a database constraint —
  `issue_completion_claims_close_requires_accepted` — refuses a row that says
  it closed an issue on anything but an `accepted` verdict. `closed_by_actor`
  is a system principal and never a user id, which is how a reader tells this
  close from a person's: a person's close is an `issue_closing_references` row
  with a `closed_by_user_id` behind it.

  Two more carry the contradiction. A later receipt that disagrees with the
  evidence an accepted claim rested on stamps `contradicted_at` and names the
  edge that disagreed. It never reopens the issue and never rewrites the
  verdict, because reopening on a later signal is a separate policy with its
  own failure modes (`ISSUE-001`). What it does is stop the claim from reading
  as uncontested.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias OpenAgents.Forge.Assignment
  alias OpenAgents.Issues.{EvidenceEntry, Issue}
  alias OpenAgents.Repositories.Repository

  @type t :: %__MODULE__{}

  @states ~w(accepted incomplete unauthorized failed not_applicable)

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

  schema "issue_completion_claims" do
    field :revision, :string
    field :state, :string
    field :reasons, {:array, :string}, default: []
    field :criteria, {:array, :map}, default: []
    field :verifier, :string
    field :falsifier, :string
    field :closed, :boolean, default: false
    field :closed_at, :utc_datetime_usec
    field :closed_by_actor, :string
    field :contradicted_at, :utc_datetime_usec
    field :contradiction_reason, :string

    belongs_to :repository, Repository, type: :binary_id
    belongs_to :issue, Issue
    belongs_to :assignment, Assignment, type: :binary_id
    belongs_to :contradicted_by_evidence, EvidenceEntry, type: :binary_id

    timestamps()
  end

  @doc "The verdicts a graded claim can hold."
  @spec states() :: [String.t()]
  def states, do: @states

  @doc false
  def changeset(claim, attrs) do
    claim
    |> cast(attrs, [
      :repository_id,
      :issue_id,
      :assignment_id,
      :revision,
      :state,
      :reasons,
      :criteria,
      :verifier,
      :falsifier,
      :closed,
      :closed_at,
      :closed_by_actor,
      :contradicted_at,
      :contradicted_by_evidence_id,
      :contradiction_reason
    ])
    |> update_change(:revision, &String.downcase/1)
    |> validate_required([:repository_id, :issue_id, :assignment_id, :revision, :state])
    |> validate_inclusion(:state, @states)
    |> validate_format(:revision, ~r/\A[0-9a-f]{7,64}\z/)
    |> validate_length(:verifier, max: 200)
    |> validate_length(:falsifier, max: 500)
    |> validate_length(:closed_by_actor, max: 200)
    |> validate_length(:contradiction_reason, max: 200)
    |> validate_close()
    |> unique_constraint([:issue_id, :assignment_id, :revision])
    |> check_constraint(:closed, name: :issue_completion_claims_close_requires_accepted)
    |> foreign_key_constraint(:repository_id)
    |> foreign_key_constraint(:issue_id)
    |> foreign_key_constraint(:assignment_id)
  end

  # The same rule the database constraint holds, refused earlier and by name so
  # a caller reads `closed_without_accepted_outcome` instead of a constraint
  # violation. Both exist on purpose: the constraint is what makes the rule
  # true of every row, and this is what makes it legible.
  defp validate_close(changeset) do
    closed? = get_field(changeset, :closed)
    state = get_field(changeset, :state)
    closed_at = get_field(changeset, :closed_at)

    cond do
      not closed? -> changeset
      state != "accepted" -> add_error(changeset, :closed, "requires an accepted outcome")
      is_nil(closed_at) -> add_error(changeset, :closed_at, "is required for a closed claim")
      true -> changeset
    end
  end
end