Manage_relationship(..., :remove) error: "changes would create a new related record"

I have two resources, Teams and Users, and these are related using the relationship resource Members. I’m trying to use manage_relationship rather than write a bunch of code.

Adding team members to a team worked “out of the box,” as expected with no problems. I’ve been stuck on deleting team members from a team for over a day… I’ve simplified the case a good bit, and am completely stuck. (I’ve been through the Ash docs, examples, and Ash Framework book looking for the solution… no luck).

Here’s the Members resource:

defmodule WasteWalk.Teams.Members do
  use Ash.Resource,
    otp_app: :waste_walk,
    domain: WasteWalk.Teams,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "team_members"
    repo WasteWalk.Repo

    references do
      reference :user, on_delete: :delete, index?: true
      reference :team, on_delete: :delete
    end
  end

  actions do
    create :create, accept: [:user_id, :team_id], primary?: true
    destroy :destroy, accept: [:user_id, :team_id], primary?: true
  end

  relationships do
    belongs_to :user, WasteWalk.Accounts.User, primary_key?: true, allow_nil?: false
    belongs_to :team, WasteWalk.Teams.Team, primary_key?: true, allow_nil?: false

    actions do
      defaults [:read, update: :*]
    end
  end
end

On the Team resource, here’s the relevant functions for adding and deleting team members:

defmodule WasteWalk.Teams.Team do
  ...
  actions do
    defaults [:read]

    create :create do
      accept [:name]
    end

    update :update do
      accept [:name]
    end

    update :add_team_member do
      argument :user_id, :uuid, allow_nil?: false
      require_atomic? false
      change manage_relationship(:user_id, :team_memberships, value_is_key: :user_id, type: :create)
    end

    destroy :remove_team_member do
      argument :user_id, :uuid, allow_nil?: false
      require_atomic? false
      # change manage_relationship(:user_id, :team_memberships, value_is_key: :user_id, type: :remove)
      # change manage_relationship(:user_id, :team_memberships, type: :append_and_remove)
      change manage_relationship(:user_id, :team_memberships, type: :remove)
    end
  end

  relationships do
    has_many :team_memberships, WasteWalk.Teams.Members
    has_many :sprints, WasteWalk.Sprints.Sprint

    many_to_many :team_members, WasteWalk.Accounts.User do
      join_relationship :team_memberships
      source_attribute_on_join_resource :team_id
      destination_attribute_on_join_resource :user_id
    end
  end

Note that in my first attempt, instead of destroy :remove_team_member I started with update :remove_team_member. This seemed to make sense since I’m not destroying the team, I’m just updating one of its relationships. In that case the first commented-out line (with value_is_key:) compiled, but ultimately resulted in:

     ** (MatchError) no match of right hand side value: {:error, %Ash.Error.Unknown{path: [:user_id, 0],  errors: [%Ash.Error.Unknown.UnknownError{error: "** (Postgrex.Error) ERROR 23502 (not_null_violation) null value in column \"team_id\" of relation \"team_members\" violates not-null constraint...

Which, in turn quickly led me down the path of using destroy (to delete the row defining the Members relationship). But that doesn’t work either… (see below).

I’ve written a a suite of tests – everything passing (creating teams, adding members, queries, etc). The only thing that’s failing is removing team members from a team.

In the :remove_team_member function you can see a few variations that I’ve tried (I’ve tried a lot of things here). The one that seems most straight-forward and obvious is currently uncommented, change manage_relationship(:user_id, :team_memberships, type: :remove).

Here’s the relevant test:

    test "only allow team leads to manage membership", context do
      {:ok, team} = WasteWalk.Teams.new_team("A team", actor: context.admin)
      {:ok, team} = WasteWalk.Teams.add_team_member(team.id, context.new_team_member.id, actor: context.admin)
      {:ok, team} = WasteWalk.Teams.add_team_member(team.id, context.new_team_lead.id, actor: context.admin)

      team = Ash.load!(team, :team_memberships, actor: context.new_team_lead)
      team |> IO.inspect(label: "team after adding new_team_member")

      assert length(team.team_memberships) == 2

      {:ok, _} = WasteWalk.Teams.remove_team_member(team.id, context.new_team_member.id, actor: context.admin)
|> IO.inspect(label: "remove_team_member call")
    end

The test fails, returning:

     ** (MatchError) no match of right hand side value: {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidRelationship{relationship: :team_memberships, message: "changes would create a new related record", splode: Ash.Error, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace<>, class: :invalid}]}}

Oddly, if I change the manage_relationship call to use :append_and_remove I get this error:

     ** (MatchError) no match of right hand side value: {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{primary_key: "1758e517-8a4e-40d8-89ac-2a3e9df9f860", resource: WasteWalk.Teams.Members, splode: Ash.Error, bread_crumbs: [], vars: [], path: [:user_id, 0], stacktrace: #Splode.Stacktrace<>, class: :invalid}]}}

Here’s the full test output:

team after adding new_team_member: %WasteWalk.Teams.Team{
  id: "f9ab360a-94c4-41fb-a4fd-971a50ea458a",
  name: "Team with Lead",
  inserted_at: ~U[2025-08-12 07:50:38.774100Z],
  updated_at: ~U[2025-08-12 07:50:38.774100Z],
  is_member: #Ash.NotLoaded<:calculation, field: :is_member>,
  team_memberships: [
    %WasteWalk.Teams.Members{
      user_id: "f3464150-0cb5-424e-a2a8-5bf822b60d16",
      team_id: "f9ab360a-94c4-41fb-a4fd-971a50ea458a",
      user: #Ash.NotLoaded<:relationship, field: :user>,
      team: #Ash.NotLoaded<:relationship, field: :team>,
      __meta__: #Ecto.Schema.Metadata<:loaded, "team_members">
    },
    %WasteWalk.Teams.Members{
      user_id: "1758e517-8a4e-40d8-89ac-2a3e9df9f860",
      team_id: "f9ab360a-94c4-41fb-a4fd-971a50ea458a",
      user: #Ash.NotLoaded<:relationship, field: :user>,
      team: #Ash.NotLoaded<:relationship, field: :team>,
      __meta__: #Ecto.Schema.Metadata<:loaded, "team_members">
    }
  ],
  sprints: #Ash.NotLoaded<:relationship, field: :sprints>,
  team_members: #Ash.NotLoaded<:relationship, field: :team_members>,
  __meta__: #Ecto.Schema.Metadata<:loaded, "teams">
}
remove_team_member call: {:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Query.NotFound{
       primary_key: "1758e517-8a4e-40d8-89ac-2a3e9df9f860",
       resource: WasteWalk.Teams.Members,
       splode: Ash.Error,
       bread_crumbs: [],
       vars: [],
       path: [:user_id, 0],
       stacktrace: #Splode.Stacktrace<>,
       class: :invalid
     }
   ]
 }}
destroy :remove_team_member do
      argument :user_id, :uuid, allow_nil?: false
      require_atomic? false
      # change manage_relationship(:user_id, :team_memberships, value_is_key: :user_id, type: :remove)
      # change manage_relationship(:user_id, :team_memberships, type: :append_and_remove)
      change manage_relationship(:user_id, :team_memberships, type: :remove)
    end

A destroy action on team would destroy the team itself. I assume that isn’t what you want to do?

If you are managing team_memberships, then you actually want to destroy that team membership, not just unrelate it from this row. So two options are:

This would be my preferred.

destroy :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :members, type: :remove)
end
destroy :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :team_memberships, type: :destroy, value_is_key: :user_id)
end

Well, I agree with you, and that’s what I initially tried.

But any attempt to add a destroy action to Members hasn’t worked. (See below).

/edit/
If I make the change you indicated to Team (using team_members in my case, not members):

destroy :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  primary? true
  require_atomic? false
  change manage_relationship(:user_id, :team_members, type: :remove)
end

That results in:

     ** (MatchError) no match of right hand side value: {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidRelationship{relationship: :team_members, message: "changes would create a new related record", splode: Ash.Error, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace<>, class: :invalid}]}}

If I misunderstood, and you mean implement it on Members, then… Above, you pasted in exactly the code I have on Team. I’d have to change it to reference the correct belongs_to relationship for it to work on Members, like so:

    destroy :remove_team_member do
      argument :user_id, :uuid, allow_nil?: false
      require_atomic? false
      change manage_relationship(:user_id, :user, type: :remove)
    end

If I move that over to Members I get this:

  1) test teams must only allow team leads to manage membership (WasteWalk.Teams.TeamTest)
     test/waste_walk/teams/team_test.exs:99
     ** (UndefinedFunctionError) function WasteWalk.Teams.Members.remove_team_member/3 is undefined or private

And in iex when I poke around, Members doesn’t offer me much to work with:

iex(2)> h WasteWalk.Teams.Members.remove_team_member/3
No documentation for WasteWalk.Teams.Members.remove_team_member/3 was found
iex(3)> h WasteWalk.Teams.Members.
default_short_name/0      input/1                   input/2                   primary_key_matches?/2

As close as I can tell, I’ve implemented a pretty simple case that exactly mirrors what’s documented in the Many to Many section of the Ash docs. The docs don’t show a destroy but extrapolating, that (above) is exactly what I tried.

/edit/
Also… if it’s on Team the API of remove_team_member(user.id) makes sense. But moving it to Members means I’ll need to change the API (I think?) to destroy(user.id, team.id). I feel like I tried that early on – but, it’s been a while… maybe I should go back and work on that…

Still very confused.

Sorry, its my bad, it should not be a destroy action.

update :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :members, type: :remove)
end
update :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :team_memberships, type: :destroy, value_is_key: :user_id)
end

What you may need is to configure Postgres references block on the join table to make sure that they are removed properly when related things are destroyed.

Also: keep in mind that if managed relationships are causing you problems, you can always write some manual code to get around it.

update :remove_team_member do
  change fn changeset, _ ->
    Ash.Changeset.after_action(changeset, fn changeset, result -> 
      # do whatever you want here
      {:ok, result}
    end)
  end
end

Yep. This is what I had, although I did it as a before_action. Guess it makes more sense as an after_action. So, with that change it would be:

defmodule WasteWalk.Teams.RemoveTeamMember do
  @moduledoc """
  Handles removal of team members using a `before_action` on the `Team` changeset. Expects a `:user_id` argument to identify the member to
  be removed; if the user is a member of the team, they will be removed.
  """

  require Ash.Query
  use Ash.Resource.Change

  def change(changeset, _opts, context) do
    Ash.Changeset.after_action(changeset, fn changeset, team ->
      with user_id <- Ash.Changeset.get_argument(changeset, :user_id),
           {:ok, member} <- find_team_member(user_id, team.id, context.actor),
           {:ok, _} <- destroy_member(member, context.actor) do
        {:ok, team}
      else
        {:error, error} -> {:error, error}
      end
    end)
  end

  defp find_team_member(user_id, team_id, actor) do
    WasteWalk.Teams.Members
    |> Ash.Query.filter(user_id == ^user_id and team_id == ^team_id)
    |> Ash.read_one(actor: actor)
  end

  defp destroy_member(member, actor) do
    member
    |> Ash.Changeset.for_destroy(:destroy)
    |> Ash.destroy(return_destroyed?: true, actor: actor)
  end
end

But this was so much code just to implement a Team.remove_team_member() function, I thought there would be an easier way… hence diving down getting it working using the DSL alone.

Also… yes, Postgres references are there on Members, so that should work… I should probably build a test just to verify tho:

  postgres do
    table "team_members"
    repo WasteWalk.Repo

    references do
      reference :user, on_delete: :delete, index?: true
      reference :team, on_delete: :delete
    end
  end

@zachdaniel, tried to take another crack at it. No go… they both generate errors.

In each case, I’m replacing the :remove_team_member action on the Team resource.

Case #1: (had to edit to use :team_members in my code):

update :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :members, type: :remove)
end

I get this runtime error (note the pecular “changes would create a new related record”), which makes no sense to me (this is one of the problems I originally reported, above, in trying to do this with the DSL).

     test/waste_walk/teams/team_test.exs:99
     ** (MatchError) no match of right hand side value: {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidRelationship{relationship: :team_members, message: "changes would create a new related record", splode: Ash.Error, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace<>, class: :invalid}]}}
     code: {:ok, _} = WasteWalk.Teams.remove_team_member(team.id, context.new_team_member.id, actor: context.new_team_lead)
     stacktrace:
       test/waste_walk/teams/team_test.exs:121: (test)

Case #2:

update :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :team_memberships, type: :destroy, value_is_key: :user_id)
end

Causes a compiler error:

** (Spark.Error.DslError) actions -> update -> remove_team_member -> change -> manage_relationship -> user_id -> team_memberships:
  The following error was raised when validating options provided to manage_relationship.
** (FunctionClauseError) no function clause matching in Ash.Changeset.manage_relationship_opts/1
    (ash 3.5.32) lib/ash/changeset/changeset.ex:4917: Ash.Changeset.manage_relationship_opts(:destroy)
    (ash 3.5.32) lib/ash/resource/verifiers/validate_manage_relationship_opts.ex:54: anonymous fn/3 in Ash.Resource.Verifiers.ValidateManagedRelationshipOpts.verify/1
    (elixir 1.18.4) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
    (ash 3.5.32) lib/ash/resource/verifiers/validate_manage_relationship_opts.ex:18: Ash.Resource.Verifiers.ValidateManagedRelationshipOpts.verify/1
    (waste_walk 0.1.0) lib/waste_walk/teams/team.ex:1: anonymous fn/1 in WasteWalk.Teams.Team.__verify_spark_dsl__/1
    (elixir 1.18.4) lib/enum.ex:4442: Enum.flat_map_list/2
    (waste_walk 0.1.0) lib/waste_walk/teams/team.ex:1: WasteWalk.Teams.Team.__verify_spark_dsl__/1
    (elixir 1.18.4) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
    (elixir 1.18.4) lib/module/parallel_checker.ex:244: Module.ParallelChecker.check_module/3
    (elixir 1.18.4) lib/module/parallel_checker.ex:90: anonymous fn/7 in Module.ParallelChecker.inner_spawn/6
    (elixir 1.18.4) lib/process.ex:896: Process.info/2
    (spark 2.2.67) lib/spark/error/dsl_error.ex:30: Spark.Error.DslError.exception/1
    (ash 3.5.32) lib/ash/resource/verifiers/validate_manage_relationship_opts.ex:69: anonymous fn/3 in Ash.Resource.Verifiers.ValidateManagedRelationshipOpts.verify/1
    (elixir 1.18.4) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
    (ash 3.5.32) lib/ash/resource/verifiers/validate_manage_relationship_opts.ex:18: Ash.Resource.Verifiers.ValidateManagedRelationshipOpts.verify/1
    (waste_walk 0.1.0) lib/waste_walk/teams/team.ex:1: anonymous fn/1 in WasteWalk.Teams.Team.__verify_spark_dsl__/1
    (elixir 1.18.4) lib/enum.ex:4442: Enum.flat_map_list/2
    (waste_walk 0.1.0) lib/waste_walk/teams/team.ex:1: WasteWalk.Teams.Team.__verify_spark_dsl__/1
    (elixir 1.18.4) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
    (elixir 1.18.4) lib/module/parallel_checker.ex:244: Module.ParallelChecker.check_module/3
    (elixir 1.18.4) lib/module/parallel_checker.ex:90: anonymous fn/7 in Module.ParallelChecker.inner_spawn/6

For now I’m leaving in my original non-DSL change() call, which works fine (see previous reply, above). But like I said, it’s just so much code and I thought there would be an easy, DSL-oriented way to achieve the same Team.remove_team_member() API. (But, maybe not… which is fine, just a little surprising).

Leaving this in:

    update :remove_team_member do
      argument :user_id, :uuid, allow_nil?: false
      require_atomic? false
      change WasteWalk.Teams.RemoveTeamMember
    end

And the RemoveTeamMember function (above reply) works.

/edit/
Just for clarity… that weird error that’s coming back on the first case (“changes would create a new related record”)… I said it makes no sense because it’s a :destroy action on the manage_relationships() call. That would be a really weird side effect. Maybe the error message is wrong, or maybe something is off internally? It seems like this should work…

Try adding debug?: true to your manage relationship opts. What you’re trying to do should absolutely work, there is something simple missing but it’s not jumping out at me. The “would create new records” is a horrible error message that I will look into. I believe it has to do with when your input doesn’t match an existing record?

Adding debug?: true didn’t add anything to the output…

Hmm…it was added recently but not that recently. It uses the Logger.debug, is your log level set to debug?

Sorry for the slow response @zachdaniel … went in a changed test.dev to include level: :debug and now I’m getting this:

18:43:07.899 [debug] QUERY OK source="teams" db=0.5ms
SELECT DISTINCT ON (t0."id") t0."id", t0."name", t0."updated_at", t0."inserted_at" FROM "teams" AS t0 LEFT OUTER JOIN "public"."team_members" AS t1 ON t0."id" = t1."team_id" LEFT OUTER JOIN "public"."users" AS u2 ON u2."id" = t1."user_id" WHERE (t0."id"::uuid = $1::uuid) AND (exists((SELECT 1 FROM "public"."users" AS su0 INNER JOIN "public"."team_members" AS st1 ON (st1."user_id" = su0."id") AND (t0."id" = st1."team_id") WHERE (su0."id"::uuid = $2::uuid)))) LIMIT $3 ["5520e3d0-4821-42b2-a6af-7b49f186cad3", "f265eaf9-8786-4632-a5b6-b99d39c0fe46", 1]
18:43:07.899 [debug] QUERY OK db=0.1ms
begin []
18:43:07.900 [debug] QUERY OK source="teams" db=0.3ms
SELECT DISTINCT ON (t0."id") t0."id" FROM "teams" AS t0 LEFT OUTER JOIN "public"."team_members" AS t1 ON t0."id" = t1."team_id" WHERE (exists((SELECT 1 FROM "public"."team_members" AS st0 WHERE (st0."user_id"::uuid = $1::uuid) AND (t0."id" = st0."team_id")))) AND (t0."id"::uuid = $2::uuid) ["f265eaf9-8786-4632-a5b6-b99d39c0fe46", "5520e3d0-4821-42b2-a6af-7b49f186cad3"]
18:43:07.900 [debug] WasteWalk.Accounts.User.read: skipped query run due to filter being false"

18:43:07.900 [debug] QUERY OK db=0.2ms
rollback []
remove_team_member() call ->: {:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidRelationship{
       relationship: :team_members,
       message: "changes would create a new related record",
       splode: Ash.Error,
       bread_crumbs: [],
       vars: [],
       path: [],
       stacktrace: #Splode.Stacktrace<>,
       class: :invalid
     }
   ]
 }}


  1) test teams must only allow team leads to manage membership (WasteWalk.Teams.TeamTest)
     test/waste_walk/teams/team_test.exs:99
     ** (MatchError) no match of right hand side value: {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidRelationship{relationship: :team_members, message: "changes would create a new related record", splode: Ash.Error, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace<>, class: :invalid}]}}
     code: {:ok, _} = WasteWalk.Teams.remove_team_member(team.id, context.new_team_member.id, actor: context.new_team_lead)
     stacktrace:
       test/waste_walk/teams/team_test.exs:116: (test)

I added a few IO.inspect() messages to help clarify.

First thing that jumps out at me is the “skipped query run due to filter being false,” which is a bit odd… keep in mind that the custom function (above) works just fine and as far as I can tell, they should be doing very nearly the same thing.

Just to see what would happen, I added:

bypass do
  authorize_if always()
end

At the top of the policies block on both Team and Members and it made no difference. (Well, it broke a bunch of other tests… but this test still fails in exactly the same way).

So I don’t think it’s actually authorization related. Maybe. :rofl:

(Actually, now I’m really curious if you would write the custom function in the same way…)

It looks like its your user resource though:

18:43:07.900 [debug] WasteWalk.Accounts.User.read: skipped query run due to filter being false"

It’s still on my docket to improve this error message. I’m 100% sure that this is just something we can fix with better messaging about why its failed.

FOr this, sorry:

update :remove_team_member do
  argument :user_id, :uuid, allow_nil?: false
  require_atomic? false
  change manage_relationship(:user_id, :team_memberships, type: :destroy, value_is_key: :user_id)
end

It should be type: :remove

And yes if manage relationship was giving me a hassle I’d just write a change w/ a hook like that. If I had my hands on your code I could probably figure it out shortly and improve the error message by like…tons. Would you be able to create a reproduction? I know you’ve already spent a bunch of time on it :sweat_smile: But if you can then I can repro and open an issue then we can make it better for everyone else who hits this case (and help us figure it out why its not working for you).

Yea, current version is using :remove (trying to use destroy generates a lengthy error from the DSL).

I will try (to do a repro). A bit under the gun this week but hopefully can poke my head up soon…