At the risk of serious simplification, let me try to explain what happens
What most of the other answers try to tell you is that these objects were not yet connected by an active record until they were saved in the database. Therefore, the association behavior that you expect is not fully connected.
Note that this line is from your first example
u.project_participations => #<ActiveRecord::Associations::CollectionProxy [#<ProjectParticipation id: nil, user_id: nil, project_id: nil, role: nil>]>
Is identical to the result from your second example.
u.project_participations => #<ActiveRecord::Associations::CollectionProxy [#<ProjectParticipation id: nil, user_id: nil, project_id: nil, role: nil>]>
This statement from your analysis of what you think rails does inaccurately:
So far so good - AR created ProjectParticipation on its own, and I can access user projects using u.projects.
The AR record did not create ProjectParticipation. You have indicated this relationship in your model. AR simply returns a proxy server for the collection, which it will have at some point in the future, which is assigned when filling, etc., you can iterate and query its members, etc.
The reason this works:
u.projects << p u.projects => #<ActiveRecord::Associations::CollectionProxy [#<Project id: nil>]>
But it is not
pp.project = p # assign project to project_participation u.project_participations << pp # assign project_participation to user u.project_participations => #<ActiveRecord::Associations::CollectionProxy [#<ProjectParticipation id: nil, user_id: nil, project_id: nil, role: nil>]> u.projects => #<ActiveRecord::Associations::CollectionProxy []>
This is the first case, you simply add objects to the array, which your user instance has direct access to. In the second example, the has_many_through relationship reflects the relationships that occur at the database level. In the second example, in order for your projects to be accessible through your user, AR must actually run a query that joins the tables and returns the data you are looking for. Since none of these objects are saved, but the database query cannot happen, so all you return is a proxy.
The last bit of code is misleading because it does not actually do what you think.
u.project_participations.map(&:project) => [#<Project id: nil>]
In this case, you have a user who directly holds an array of ProjectParticipations, one of which directly holds the project so that it works. In fact, it does not use the has_many_through mechanism, as you think.
Again, this makes things a little easier, but it's a general idea.