lib/openagents/memories/admission.ex

58e6347eeb72 · 8 KB

defmodule OpenAgents.Memories.Admission do
  @moduledoc """
  One record about one system memory: a verdict on it, a challenge to it, or a
  refutation of a challenge.

  All three are receipts, not assertions. The registry does not turn a promise
  green because someone said so, and the memory store does not admit a claim
  because its author wrote `admitted` on it: the records here are what a status
  is derived from, and the `admission` field on the candidate is only what the
  author claimed.

  Every record is append-only. There is no `updated_at` and nothing updates
  one, so a steward who changes their mind writes a second record rather than
  editing the first, and both stay readable. A reversal is a further record for
  the same reason: it is how the store keeps the argument rather than the last
  word in it.

  ## The three roles

  * `admission` — a steward's verdict, `admitted` or `rejected`, slug
    `adm:<memory>`. Only a steward writes one.
  * `challenge` — any account's statement that the claim is wrong, slug
    `chl:<memory>`. A challenge carrying its own `evidence_refs` is an
    *evidenced* challenge and suspends its target; one without is recorded and
    changes nothing.
  * `refutation` — a steward's resolution of one challenge, slug
    `ref:<challenge>`, restoring the target. Only a steward writes one.

  `author_id` is who wrote the record, whichever role it carries. The column
  was `steward_id` while `admission` was the only role written; anyone may
  challenge, so the steward rule lives at the write path rather than in a
  column name that would be wrong on two roles out of three.

  ## What the database holds rather than the changeset

  `memory_bucket` rides the row so the composite foreign key
  `(memory_id, memory_bucket) -> memories (id, bucket)` can pin the target to
  the `system` bucket. That is what lets a write refuse a record naming a
  `user` or `learned` row without reading `memories` at all, which is how these
  paths stay clear of the account boundary MEMORY-010 draws.

  `memory_promoted` is always the literal `false`, and the composite foreign key
  `(memory_id, memory_promoted) -> memories (id, promoted)` is what refuses a
  record naming a promotion tombstone. A tombstone is a pointer at a reviewed
  knowledge-base stance, not a claim: there is nothing to admit, nothing to
  challenge, and the knowledge base's own dispute path is a documentation
  change. See `OpenAgents.Memories.Promotions`.

  `challenge_role` is always the literal `challenge` on a refutation and null
  elsewhere. It exists so the third composite foreign key,
  `(challenge_id, memory_id, challenge_role) -> (id, memory_id, role)`, can
  insist that a refutation names a challenge — and one against the same memory
  it claims to restore. PostgreSQL will not put a literal in a foreign key, so
  the literal is a column the `memory_admissions_shape` constraint pins.
  """

  use Ecto.Schema

  import Ecto.Changeset

  alias OpenAgents.Accounts.User
  alias OpenAgents.Memories.{Evidence, Memory}

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

  @roles ~w(admission challenge refutation)
  @verdicts ~w(admitted rejected)
  @ground_characters 2_000

  schema "memory_admissions" do
    belongs_to :memory, Memory
    field :memory_bucket, :string, default: "system"
    field :memory_promoted, :boolean, default: false
    belongs_to :author, User
    belongs_to :challenge, __MODULE__
    field :challenge_role, :string
    field :slug, :string
    field :role, :string, default: "admission"
    field :verdict, :string
    field :ground, :string
    field :evidence_refs, {:array, :map}
    timestamps(updated_at: false)
  end

  @type t :: %__MODULE__{}

  @doc "The record roles the memory store recognises."
  @spec roles() :: [String.t()]
  def roles, do: @roles

  @doc "The verdicts an admission record may carry."
  @spec verdicts() :: [String.t()]
  def verdicts, do: @verdicts

  @doc "The longest ground the store accepts, in characters."
  @spec ground_characters() :: pos_integer()
  def ground_characters, do: @ground_characters

  @doc """
  Whether this record is an evidenced challenge.

  The one distinction recall turns on: an evidenced challenge suspends its
  target, an unevidenced one is recorded and changes nothing. An empty list
  cannot reach the table, so absence is the only unevidenced shape there is.
  """
  @spec evidenced_challenge?(t()) :: boolean()
  def evidenced_challenge?(%__MODULE__{role: "challenge", evidence_refs: [_first | _rest]}),
    do: true

  def evidenced_challenge?(%__MODULE__{}), do: false

  @doc """
  Validates one admission record: a steward's verdict on a candidate.

  The author and the target are set on the struct rather than cast, so a
  request body can name neither who admitted nor, by extension, on whose
  authority.
  """
  @spec changeset(t(), map()) :: Ecto.Changeset.t()
  def changeset(record, attrs) do
    record
    |> cast(attrs, [:verdict, :ground])
    |> put_role("admission")
    |> put_slug(:memory_id, "adm:")
    |> validate_required([:verdict])
    |> validate_inclusion(:verdict, @verdicts)
    |> validate_shape()
  end

  @doc """
  Validates one challenge: any account's statement that an admitted claim is
  wrong, and the ground for saying so.

  `evidence_refs` is the only field beyond the ground a caller may name, and it
  is optional. Carrying it is what makes the challenge evidenced, and an
  evidenced challenge is the one that suspends its target — so the shape is
  checked here and again at the table.
  """
  @spec challenge_changeset(t(), map()) :: Ecto.Changeset.t()
  def challenge_changeset(record, attrs) do
    record
    |> cast(attrs, [:ground, :evidence_refs])
    |> put_role("challenge")
    |> put_slug(:memory_id, "chl:")
    |> Evidence.validate(:evidence_refs)
    |> validate_shape()
  end

  @doc """
  Validates one refutation: a steward's resolution of one challenge.

  The challenge and the memory it restores are set on the struct, so a request
  body names neither. The ground is all a caller supplies.
  """
  @spec refutation_changeset(t(), map()) :: Ecto.Changeset.t()
  def refutation_changeset(record, attrs) do
    record
    |> cast(attrs, [:ground])
    |> put_role("refutation")
    |> put_change(:challenge_role, "challenge")
    |> put_slug(:challenge_id, "ref:")
    |> validate_required([:challenge_id])
    |> foreign_key_constraint(:challenge_id,
      name: :memory_admissions_challenge_fkey,
      message: "names no challenge against this memory"
    )
    |> validate_shape()
  end

  # Everything the three roles agree on. `role` is put rather than cast, so no
  # request body can turn a challenge into a refutation and no caller reaches a
  # steward-only role through the path that does not check for one.
  defp put_role(changeset, role) do
    changeset
    |> update_change(:ground, &trim/1)
    |> put_change(:role, role)
    |> put_change(:memory_bucket, "system")
    |> put_change(:memory_promoted, false)
  end

  defp validate_shape(changeset) do
    changeset
    |> validate_required([:memory_id, :author_id, :slug, :role, :ground])
    |> validate_inclusion(:role, @roles)
    |> validate_length(:ground, min: 1, max: @ground_characters, count: :graphemes)
    |> foreign_key_constraint(:author_id)
    # The composite key, named as the database names it. A record for a `user`
    # or `learned` row fails here rather than in a read that had to cross an
    # account to check.
    |> foreign_key_constraint(:memory_id,
      name: :memory_admissions_memory_fkey,
      message: "names no system memory"
    )
    # The second composite key. A promotion tombstone is a pointer at a reviewed
    # stance rather than a claim, so no record may name one, and the refusal is
    # the foreign key rather than a read of the row.
    |> foreign_key_constraint(:memory_id,
      name: :memory_admissions_promotion_fkey,
      message: "names a promoted memory, which is a tombstone rather than a claim"
    )
    |> check_constraint(:verdict, name: :memory_admissions_shape)
    |> check_constraint(:evidence_refs,
      name: :memory_admissions_shape,
      message: "does not satisfy the record shape"
    )
  end

  defp put_slug(changeset, field, prefix) do
    case get_field(changeset, field) do
      id when is_binary(id) -> put_change(changeset, :slug, prefix <> id)
      _absent -> changeset
    end
  end

  defp trim(value) when is_binary(value), do: String.trim(value)
  defp trim(value), do: value
end