plutonium 0.49.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +37 -0
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1323 -1184
  14. data/app/assets/plutonium.js.map +4 -4
  15. data/app/assets/plutonium.min.js +50 -49
  16. data/app/assets/plutonium.min.js.map +4 -4
  17. data/app/views/plutonium/_resource_header.html.erb +4 -4
  18. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  19. data/app/views/resource/_resource_grid.html.erb +1 -0
  20. data/config/brakeman.ignore +25 -2
  21. data/docs/.vitepress/config.ts +37 -27
  22. data/docs/getting-started/index.md +22 -29
  23. data/docs/getting-started/installation.md +37 -80
  24. data/docs/getting-started/tutorial/index.md +4 -5
  25. data/docs/guides/adding-resources.md +66 -377
  26. data/docs/guides/authentication.md +94 -463
  27. data/docs/guides/authorization.md +124 -370
  28. data/docs/guides/creating-packages.md +94 -296
  29. data/docs/guides/custom-actions.md +121 -441
  30. data/docs/guides/index.md +22 -42
  31. data/docs/guides/multi-tenancy.md +116 -187
  32. data/docs/guides/nested-resources.md +103 -431
  33. data/docs/guides/search-filtering.md +123 -240
  34. data/docs/guides/testing.md +5 -4
  35. data/docs/guides/theming.md +157 -407
  36. data/docs/guides/troubleshooting.md +5 -3
  37. data/docs/guides/user-invites.md +106 -425
  38. data/docs/guides/user-profile.md +76 -243
  39. data/docs/index.md +1 -1
  40. data/docs/reference/app/generators.md +517 -0
  41. data/docs/reference/app/index.md +158 -0
  42. data/docs/reference/app/packages.md +146 -0
  43. data/docs/reference/app/portals.md +377 -0
  44. data/docs/reference/auth/accounts.md +230 -0
  45. data/docs/reference/auth/index.md +88 -0
  46. data/docs/reference/auth/profile.md +185 -0
  47. data/docs/reference/behavior/controllers.md +395 -0
  48. data/docs/reference/behavior/index.md +22 -0
  49. data/docs/reference/behavior/interactions.md +341 -0
  50. data/docs/reference/behavior/policies.md +417 -0
  51. data/docs/reference/index.md +56 -49
  52. data/docs/reference/resource/actions.md +423 -0
  53. data/docs/reference/resource/definition.md +508 -0
  54. data/docs/reference/resource/index.md +50 -0
  55. data/docs/reference/resource/model.md +348 -0
  56. data/docs/reference/resource/query.md +305 -0
  57. data/docs/reference/tenancy/entity-scoping.md +361 -0
  58. data/docs/reference/tenancy/index.md +36 -0
  59. data/docs/reference/tenancy/invites.md +393 -0
  60. data/docs/reference/tenancy/nested-resources.md +267 -0
  61. data/docs/reference/testing/index.md +287 -0
  62. data/docs/reference/ui/assets.md +400 -0
  63. data/docs/reference/ui/components.md +165 -0
  64. data/docs/reference/ui/displays.md +104 -0
  65. data/docs/reference/ui/forms.md +284 -0
  66. data/docs/reference/ui/index.md +30 -0
  67. data/docs/reference/ui/layouts.md +106 -0
  68. data/docs/reference/ui/pages.md +189 -0
  69. data/docs/reference/ui/tables.md +117 -0
  70. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  71. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  72. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  73. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  74. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  75. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  76. data/gemfiles/rails_7.gemfile.lock +1 -1
  77. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  78. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  79. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  80. data/lib/generators/pu/invites/install_generator.rb +1 -0
  81. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  82. data/lib/plutonium/action/base.rb +44 -1
  83. data/lib/plutonium/action/interactive.rb +1 -1
  84. data/lib/plutonium/configuration.rb +4 -0
  85. data/lib/plutonium/definition/actions.rb +3 -0
  86. data/lib/plutonium/definition/base.rb +8 -0
  87. data/lib/plutonium/definition/index_views.rb +95 -0
  88. data/lib/plutonium/definition/metadata.rb +40 -0
  89. data/lib/plutonium/helpers/turbo_helper.rb +12 -1
  90. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  91. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  92. data/lib/plutonium/query/base.rb +8 -0
  93. data/lib/plutonium/query/filters/association.rb +30 -8
  94. data/lib/plutonium/query/filters/boolean.rb +5 -0
  95. data/lib/plutonium/resource/controller.rb +1 -0
  96. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  97. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  98. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  99. data/lib/plutonium/resource/definition.rb +42 -0
  100. data/lib/plutonium/resource/policy.rb +7 -0
  101. data/lib/plutonium/resource/query_object.rb +64 -6
  102. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  103. data/lib/plutonium/testing/resource_definition.rb +2 -2
  104. data/lib/plutonium/ui/action_button.rb +4 -2
  105. data/lib/plutonium/ui/component/kit.rb +12 -0
  106. data/lib/plutonium/ui/component/methods.rb +4 -0
  107. data/lib/plutonium/ui/display/base.rb +3 -1
  108. data/lib/plutonium/ui/display/resource.rb +109 -25
  109. data/lib/plutonium/ui/display/theme.rb +2 -1
  110. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  111. data/lib/plutonium/ui/empty_card.rb +1 -1
  112. data/lib/plutonium/ui/form/base.rb +35 -3
  113. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  114. data/lib/plutonium/ui/form/components/json.rb +58 -0
  115. data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
  116. data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
  117. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  118. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  119. data/lib/plutonium/ui/form/resource.rb +45 -10
  120. data/lib/plutonium/ui/form/theme.rb +1 -1
  121. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  122. data/lib/plutonium/ui/grid/card.rb +235 -0
  123. data/lib/plutonium/ui/grid/resource.rb +149 -0
  124. data/lib/plutonium/ui/layout/base.rb +38 -1
  125. data/lib/plutonium/ui/layout/header.rb +1 -2
  126. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  127. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  128. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  129. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  130. data/lib/plutonium/ui/modal/base.rb +109 -0
  131. data/lib/plutonium/ui/modal/centered.rb +21 -0
  132. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  133. data/lib/plutonium/ui/page/base.rb +18 -6
  134. data/lib/plutonium/ui/page/edit.rb +13 -1
  135. data/lib/plutonium/ui/page/index.rb +40 -1
  136. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  137. data/lib/plutonium/ui/page/new.rb +13 -1
  138. data/lib/plutonium/ui/page/show.rb +8 -1
  139. data/lib/plutonium/ui/page_header.rb +8 -13
  140. data/lib/plutonium/ui/panel.rb +10 -19
  141. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  142. data/lib/plutonium/ui/tab_list.rb +29 -7
  143. data/lib/plutonium/ui/table/base.rb +106 -0
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  145. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  146. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  147. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  148. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  149. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  150. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  151. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  152. data/lib/plutonium/ui/table/resource.rb +158 -89
  153. data/lib/plutonium/ui/table/theme.rb +14 -5
  154. data/lib/plutonium/version.rb +1 -1
  155. data/lib/plutonium.rb +14 -0
  156. data/lib/tasks/release.rake +15 -1
  157. data/package.json +10 -10
  158. data/src/css/components.css +304 -131
  159. data/src/css/slim_select.css +4 -0
  160. data/src/css/tokens.css +101 -85
  161. data/src/js/controllers/autosubmit_controller.js +24 -0
  162. data/src/js/controllers/bulk_actions_controller.js +15 -16
  163. data/src/js/controllers/capture_url_controller.js +14 -0
  164. data/src/js/controllers/filter_panel_controller.js +77 -19
  165. data/src/js/controllers/frame_navigator_controller.js +34 -6
  166. data/src/js/controllers/icon_rail_controller.js +22 -0
  167. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  168. data/src/js/controllers/register_controllers.js +16 -0
  169. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  170. data/src/js/controllers/row_click_controller.js +21 -0
  171. data/src/js/controllers/slim_select_controller.js +61 -0
  172. data/src/js/controllers/table_column_menu_controller.js +43 -0
  173. data/src/js/controllers/table_header_controller.js +16 -0
  174. data/src/js/controllers/view_switcher_controller.js +29 -0
  175. data/src/js/turbo/turbo_actions.js +33 -0
  176. data/yarn.lock +553 -543
  177. metadata +71 -32
  178. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  179. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  180. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  181. data/.claude/skills/plutonium-definition/SKILL.md +0 -1138
  182. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  183. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  184. data/.claude/skills/plutonium-installation/SKILL.md +0 -325
  185. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  186. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  187. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  188. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  189. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  190. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  191. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  192. data/.claude/skills/plutonium-views/SKILL.md +0 -592
  193. data/docs/reference/assets/index.md +0 -496
  194. data/docs/reference/controller/index.md +0 -412
  195. data/docs/reference/definition/actions.md +0 -449
  196. data/docs/reference/definition/fields.md +0 -383
  197. data/docs/reference/definition/index.md +0 -268
  198. data/docs/reference/definition/query.md +0 -351
  199. data/docs/reference/generators/index.md +0 -648
  200. data/docs/reference/interaction/index.md +0 -449
  201. data/docs/reference/model/features.md +0 -248
  202. data/docs/reference/model/index.md +0 -218
  203. data/docs/reference/policy/index.md +0 -456
  204. data/docs/reference/portal/index.md +0 -379
  205. data/docs/reference/views/forms.md +0 -411
  206. data/docs/reference/views/index.md +0 -501
@@ -0,0 +1,361 @@
1
+ # Entity Scoping
2
+
3
+ Multi-tenant data isolation. Built on three cooperating pieces — portal, policy, model — that together ensure queries never leak across tenants.
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly.
8
+ - **Don't rely on `super`** inside `relation_scope` — call `default_relation_scope(relation)` by name.
9
+ - **Fix the MODEL, not the policy.** If `associated_with` can't resolve, declare an association path (`belongs_to`, `has_one :through`) OR a custom `associated_with_<entity>` scope on the model. Never paper over it with a `where` in the policy.
10
+ - **Compound uniqueness scoped to the tenant FK** — `validates :code, uniqueness: {scope: :organization_id}`.
11
+ - **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller.
12
+
13
+ ## The three pieces
14
+
15
+ | Piece | Role | Where |
16
+ |---|---|---|
17
+ | **Portal** | Declares the entity class and resolution strategy | `scope_to_entity Organization, strategy: :path` in the engine |
18
+ | **Policy** | Applies the scope to every collection query | `default_relation_scope(relation)` (auto-called) |
19
+ | **Model** | Resolves the scope path | Direct `belongs_to`, `has_one :through`, or custom scope |
20
+
21
+ `default_relation_scope` is enforced — if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
22
+
23
+ ## `associated_with` resolution
24
+
25
+ `Model.associated_with(entity)` resolves in this order:
26
+
27
+ 1. **Custom scope** `associated_with_<entity_name>` (e.g. `associated_with_organization`) — highest priority, full SQL control.
28
+ 2. **Direct `belongs_to` to the entity class** — `WHERE <entity>_id = ?`, most efficient.
29
+ 3. **`has_one` / `has_one :through` to the entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
30
+ 4. **Reverse `has_many` from the entity** — JOIN required, logs a warning (less efficient).
31
+
32
+ If none apply:
33
+
34
+ ```
35
+ Could not resolve the association between 'Model' and 'Entity'
36
+ ```
37
+
38
+ Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
39
+
40
+ ## Three model shapes
41
+
42
+ The `associated_with` resolver handles three common shapes. Pick the lightest that fits.
43
+
44
+ ### Shape 1: Direct child (`belongs_to` the entity)
45
+
46
+ ```ruby
47
+ class Organization < ResourceRecord
48
+ has_many :projects
49
+ end
50
+
51
+ class Project < ResourceRecord
52
+ belongs_to :organization
53
+ end
54
+
55
+ Project.associated_with(org)
56
+ # => Project.where(organization: org) — simple WHERE, most efficient
57
+ ```
58
+
59
+ Auto-detected. Use this when the model naturally has a direct FK to the entity.
60
+
61
+ ### Shape 2: Join table (membership-style)
62
+
63
+ A join table linking users to entities, where the entity is reachable via one of the `belongs_to`:
64
+
65
+ ```ruby
66
+ class User < ResourceRecord
67
+ has_many :memberships
68
+ has_many :organizations, through: :memberships
69
+ end
70
+
71
+ class Organization < ResourceRecord
72
+ has_many :memberships
73
+ has_many :users, through: :memberships
74
+ end
75
+
76
+ class Membership < ResourceRecord
77
+ belongs_to :user
78
+ belongs_to :organization # ← auto-detection finds :organization via belongs_to
79
+ end
80
+
81
+ Membership.associated_with(org)
82
+ # => Membership.where(organization: org)
83
+ ```
84
+
85
+ If the join table is itself a parent and the scoped target is two hops away, add `has_one :through`:
86
+
87
+ ```ruby
88
+ class ProjectMember < ResourceRecord
89
+ belongs_to :project
90
+ belongs_to :user
91
+ has_one :organization, through: :project # ← enables auto-scoping
92
+ end
93
+ ```
94
+
95
+ Now `ProjectMember.associated_with(org)` resolves via the `has_one :through`.
96
+
97
+ ### Shape 3: Grandchild (multi-hop via `has_one :through`)
98
+
99
+ ```ruby
100
+ class Organization < ResourceRecord
101
+ has_many :projects
102
+ end
103
+
104
+ class Project < ResourceRecord
105
+ belongs_to :organization
106
+ has_many :tasks
107
+ end
108
+
109
+ class Task < ResourceRecord
110
+ belongs_to :project
111
+ has_one :organization, through: :project # ← critical
112
+ end
113
+
114
+ # Deeper
115
+ class Comment < ResourceRecord
116
+ belongs_to :task
117
+ has_one :project, through: :task
118
+ has_one :organization, through: :project # ← enables auto-scoping at 3 hops
119
+ end
120
+ ```
121
+
122
+ `Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
123
+
124
+ ::: tip Declaring `has_one :through` is the lightest fix
125
+ For grandchildren, the `has_one :through` on the model is all you need — `associated_with` finds it automatically. No policy override needed.
126
+ :::
127
+
128
+ ### When to fall back to a custom scope
129
+
130
+ Use a custom `associated_with_<entity>` scope when:
131
+
132
+ - The path is polymorphic.
133
+ - The path needs conditional logic.
134
+ - You want explicit SQL for performance (e.g. avoid a multi-join chain).
135
+
136
+ ```ruby
137
+ class Comment < ResourceRecord
138
+ scope :associated_with_organization, ->(org) do
139
+ joins(task: :project).where(projects: {organization_id: org.id})
140
+ end
141
+ end
142
+ ```
143
+
144
+ Plutonium picks this up **before** trying association detection.
145
+
146
+ ## `relation_scope` — safe override patterns
147
+
148
+ `default_relation_scope(relation)` does two things:
149
+
150
+ 1. If a **parent** is present (nested resource), scopes via the parent association.
151
+ 2. Otherwise, applies `relation.associated_with(entity_scope)`.
152
+
153
+ ### Correct
154
+
155
+ ```ruby
156
+ # ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
157
+
158
+ # ✅ Extra filters on top
159
+ relation_scope do |relation|
160
+ default_relation_scope(relation).where(archived: false)
161
+ end
162
+
163
+ # ✅ Role-based
164
+ relation_scope do |relation|
165
+ relation = default_relation_scope(relation)
166
+ user.admin? ? relation : relation.where(author: user)
167
+ end
168
+ ```
169
+
170
+ ### Wrong
171
+
172
+ ```ruby
173
+ # ❌ Manually filtering by entity — bypasses default_relation_scope
174
+ relation_scope { |r| r.where(organization: current_scoped_entity) }
175
+
176
+ # ❌ Manual joins — same problem
177
+ relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
178
+
179
+ # ❌ Missing default_relation_scope entirely — raises at runtime
180
+ relation_scope { |r| r.where(published: true) }
181
+ ```
182
+
183
+ ::: danger Don't use `super`
184
+ `super` inside `relation_scope` is unreliable — its semantics depend on how ActionPolicy's DSL registered the scope. Call `default_relation_scope(relation)` by name.
185
+ :::
186
+
187
+ ### Intentionally skipping the scope
188
+
189
+ Rare, but possible:
190
+
191
+ ```ruby
192
+ relation_scope do |relation|
193
+ skip_default_relation_scope!
194
+ relation
195
+ end
196
+ ```
197
+
198
+ Before reaching for this, consider a separate, unscoped portal.
199
+
200
+ ## Portal entity strategies
201
+
202
+ The portal declares how the current entity is resolved from the request.
203
+
204
+ ### Path strategy (most common)
205
+
206
+ ```ruby
207
+ module CustomerPortal
208
+ class Engine < Rails::Engine
209
+ include Plutonium::Portal::Engine
210
+
211
+ config.after_initialize do
212
+ scope_to_entity Organization, strategy: :path
213
+ end
214
+ end
215
+ end
216
+ ```
217
+
218
+ Routes become `/organizations/:organization_id/posts`. The portal extracts `params[:organization_id]` and loads the entity automatically.
219
+
220
+ ### Custom strategy (subdomain, session, etc.)
221
+
222
+ ```ruby
223
+ module CustomerPortal::Concerns::Controller
224
+ extend ActiveSupport::Concern
225
+ include Plutonium::Portal::Controller
226
+
227
+ private
228
+
229
+ def current_organization
230
+ @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
231
+ end
232
+ end
233
+
234
+ # Engine
235
+ scope_to_entity Organization, strategy: :current_organization
236
+ ```
237
+
238
+ The strategy symbol must match a method name on the controller concern.
239
+
240
+ ### Custom param key
241
+
242
+ When the param name differs from the entity model name:
243
+
244
+ ```ruby
245
+ scope_to_entity Organization, strategy: :path, param_key: :org_id
246
+ # → /orgs/:org_id/posts
247
+ ```
248
+
249
+ ### Accessing the scoped entity
250
+
251
+ ```ruby
252
+ # Controller / views
253
+ current_scoped_entity # => current Organization
254
+ scoped_to_entity? # => true / false
255
+
256
+ # Policy
257
+ entity_scope # => current Organization
258
+ ```
259
+
260
+ ## Cross-tenant operations
261
+
262
+ ### Super-admin portal — no scoping
263
+
264
+ Create a separate portal without `scope_to_entity`:
265
+
266
+ ```ruby
267
+ module SuperAdminPortal
268
+ class Engine < Rails::Engine
269
+ include Plutonium::Portal::Engine
270
+
271
+ # No scope_to_entity — sees all tenants
272
+ end
273
+ end
274
+ ```
275
+
276
+ This portal's policies see everything. Don't enable public signup here.
277
+
278
+ ### Conditional scoping
279
+
280
+ ```ruby
281
+ class PostPolicy < ResourcePolicy
282
+ relation_scope do |relation|
283
+ return default_relation_scope(relation).where(category: :public) if user.guest?
284
+ default_relation_scope(relation)
285
+ end
286
+ end
287
+ ```
288
+
289
+ ## Multiple associations to the same entity class
290
+
291
+ Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises:
292
+
293
+ ```
294
+ Match has multiple associations to Competition::Team: home_team, away_team.
295
+ Plutonium cannot auto-detect which one to use for entity scoping.
296
+ Override `scoped_entity_association` in your controller to specify the association.
297
+ ```
298
+
299
+ Override on the controller:
300
+
301
+ ```ruby
302
+ class MatchesController < ::ResourceController
303
+ private
304
+ def scoped_entity_association = :home_team
305
+ end
306
+ ```
307
+
308
+ ## `param_key` differs from association name
309
+
310
+ Plutonium matches by **class**, not param key:
311
+
312
+ ```ruby
313
+ # Portal config
314
+ scope_to_entity Competition::Team, param_key: :team
315
+
316
+ # Model — association name differs from param_key, but Plutonium finds by class
317
+ class Match < ApplicationRecord
318
+ belongs_to :competition_team # ← Plutonium auto-detects this
319
+ end
320
+ ```
321
+
322
+ ## How the pieces fit together
323
+
324
+ 1. An admin opens `/organizations/42/projects`.
325
+ 2. Portal's `scope_to_entity Organization, strategy: :path` extracts `42`, loads the `Organization`, sets `current_scoped_entity`.
326
+ 3. The controller calls the policy. The policy's inherited `relation_scope` calls `default_relation_scope(relation)`.
327
+ 4. `default_relation_scope` has no parent (top-level nested-from-portal), so it calls `relation.associated_with(current_scoped_entity)`.
328
+ 5. `Project.associated_with(org)` resolves via the direct `belongs_to :organization` → `Project.where(organization: org)`.
329
+ 6. Only that organization's projects render. Records from other orgs are invisible.
330
+
331
+ Any model that can't be reached from the entity via these rules MUST declare a `has_one :through` or a custom scope.
332
+
333
+ ## Compound uniqueness
334
+
335
+ Always scope tenant-affecting uniqueness constraints:
336
+
337
+ ```ruby
338
+ class Property < ResourceRecord
339
+ belongs_to :organization
340
+ validates :code, uniqueness: {scope: :organization_id} # ← critical
341
+ end
342
+ ```
343
+
344
+ Without the scope, uniqueness leaks across tenants — Org A and Org B could collide on the same code.
345
+
346
+ ## Gotchas
347
+
348
+ - **Policy tries to filter by entity directly.** Wrong — bypasses `default_relation_scope`. Add the association path to the model instead.
349
+ - **`super` inside `relation_scope`.** Unreliable. Use `default_relation_scope(relation)` explicitly.
350
+ - **Multiple associations to the same entity class.** Override `scoped_entity_association`.
351
+ - **`param_key` differs from association name.** Fine — Plutonium finds the association by class.
352
+ - **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
353
+ - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly — never leave a `where` bypass in code.
354
+
355
+ ## Related
356
+
357
+ - [Nested resources](./nested-resources) — parent scoping takes precedence over entity scoping
358
+ - [Invites](./invites) — membership-based onboarding
359
+ - [Resource › Model](/reference/resource/model) — `associated_with`, model conventions
360
+ - [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
361
+ - [App › Portals](/reference/app/portals) — `scope_to_entity` engine config
@@ -0,0 +1,36 @@
1
+ # Tenancy Reference
2
+
3
+ Three closely-coupled concerns:
4
+
5
+ 1. **[Entity scoping](./entity-scoping)** — every record belongs to a tenant; queries filter automatically.
6
+ 2. **[Nested resources](./nested-resources)** — parent/child URLs; parent scoping takes precedence over entity scoping.
7
+ 3. **[Invites](./invites)** — onboarding users into a tenant's membership.
8
+
9
+ ## How entity scoping fits together
10
+
11
+ Three cooperating pieces:
12
+
13
+ | Piece | Role |
14
+ |---|---|
15
+ | **Portal** | Declares the entity class and how to resolve it from the request (`scope_to_entity Organization, strategy: :path`). |
16
+ | **Policy** | `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)` on every collection query. Enforced via `verify_default_relation_scope_applied!`. |
17
+ | **Model** | `associated_with(entity)` resolves via custom scope, direct association, or `has_one :through`. |
18
+
19
+ Configure the portal once. The policy and model conventions then carry tenancy automatically.
20
+
21
+ ## 🚨 Critical (applies to all three sub-pages)
22
+
23
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly — not `super`.
24
+ - **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
25
+ - **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope.
26
+ - **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
27
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
28
+ - **Invite email must match the accepting user's email.** Security feature — don't disable `enforce_email?` lightly.
29
+
30
+ ## Related
31
+
32
+ - [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
33
+ - [Resource › Model](/reference/resource/model) — model layer (associations, `has_cents`, SGID)
34
+ - [App › Portals](/reference/app/portals) — `scope_to_entity` engine config
35
+ - [Guides › Multi-tenancy](/guides/multi-tenancy) — task-oriented walkthrough
36
+ - [Guides › User invites](/guides/user-invites) — invitation setup recipe