plutonium 0.50.0 → 0.52.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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +574 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +167 -302
  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 +674 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +9 -6
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +44 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1010 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +38 -29
  18. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  19. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  20. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  21. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  22. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  23. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  24. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  25. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  26. data/docs/.vitepress/theme/custom.css +144 -0
  27. data/docs/.vitepress/theme/index.ts +58 -1
  28. data/docs/getting-started/index.md +33 -57
  29. data/docs/getting-started/installation.md +37 -80
  30. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  31. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  32. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  33. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  34. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  36. data/docs/getting-started/tutorial/index.md +4 -5
  37. data/docs/guides/adding-resources.md +66 -377
  38. data/docs/guides/authentication.md +98 -462
  39. data/docs/guides/authorization.md +124 -370
  40. data/docs/guides/creating-packages.md +93 -298
  41. data/docs/guides/custom-actions.md +126 -441
  42. data/docs/guides/customizing-ui.md +258 -0
  43. data/docs/guides/index.md +49 -52
  44. data/docs/guides/multi-tenancy.md +123 -186
  45. data/docs/guides/nested-resources.md +137 -396
  46. data/docs/guides/search-filtering.md +127 -238
  47. data/docs/guides/testing.md +10 -5
  48. data/docs/guides/theming.md +168 -405
  49. data/docs/guides/troubleshooting.md +5 -3
  50. data/docs/guides/user-invites.md +112 -425
  51. data/docs/guides/user-profile.md +82 -241
  52. data/docs/index.md +10 -219
  53. data/docs/public/asciinema/home-scaffold.cast +305 -0
  54. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  55. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  56. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  57. data/docs/public/images/guides/nested-inputs.png +0 -0
  58. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  59. data/docs/public/images/guides/search-filtering-index.png +0 -0
  60. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  61. data/docs/public/images/guides/theming-after.png +0 -0
  62. data/docs/public/images/guides/theming-before.png +0 -0
  63. data/docs/public/images/guides/user-invites-landing.png +0 -0
  64. data/docs/public/images/guides/user-profile-edit.png +0 -0
  65. data/docs/public/images/guides/user-profile-show.png +0 -0
  66. data/docs/public/images/home-index.png +0 -0
  67. data/docs/public/images/home-new.png +0 -0
  68. data/docs/public/images/home-show.png +0 -0
  69. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  70. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  71. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  72. data/docs/public/images/tutorial/02-new-form.png +0 -0
  73. data/docs/public/images/tutorial/03-create-account.png +0 -0
  74. data/docs/public/images/tutorial/03-login.png +0 -0
  75. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  76. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  77. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  78. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  79. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  80. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  81. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  82. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  83. data/docs/reference/app/generators.md +517 -0
  84. data/docs/reference/app/index.md +158 -0
  85. data/docs/reference/app/packages.md +146 -0
  86. data/docs/reference/app/portals.md +377 -0
  87. data/docs/reference/auth/accounts.md +229 -0
  88. data/docs/reference/auth/index.md +88 -0
  89. data/docs/reference/auth/profile.md +185 -0
  90. data/docs/reference/behavior/controllers.md +395 -0
  91. data/docs/reference/behavior/index.md +22 -0
  92. data/docs/reference/behavior/interactions.md +341 -0
  93. data/docs/reference/behavior/policies.md +417 -0
  94. data/docs/reference/index.md +67 -48
  95. data/docs/reference/resource/actions.md +423 -0
  96. data/docs/reference/resource/definition.md +508 -0
  97. data/docs/reference/resource/index.md +50 -0
  98. data/docs/reference/resource/model.md +348 -0
  99. data/docs/reference/resource/query.md +305 -0
  100. data/docs/reference/tenancy/entity-scoping.md +368 -0
  101. data/docs/reference/tenancy/index.md +36 -0
  102. data/docs/reference/tenancy/invites.md +400 -0
  103. data/docs/reference/tenancy/nested-resources.md +267 -0
  104. data/docs/reference/testing/index.md +287 -0
  105. data/docs/reference/ui/assets.md +400 -0
  106. data/docs/reference/ui/components.md +165 -0
  107. data/docs/reference/ui/displays.md +104 -0
  108. data/docs/reference/ui/forms.md +284 -0
  109. data/docs/reference/ui/index.md +30 -0
  110. data/docs/reference/ui/layouts.md +106 -0
  111. data/docs/reference/ui/pages.md +189 -0
  112. data/docs/reference/ui/tables.md +121 -0
  113. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  114. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  115. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  116. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  117. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  118. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  119. data/gemfiles/rails_7.gemfile.lock +1 -1
  120. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  121. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  122. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  123. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  124. data/lib/generators/pu/invites/install_generator.rb +45 -0
  125. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  126. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  127. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  128. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  129. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  130. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  131. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  132. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  133. data/lib/generators/pu/saas/membership/USAGE +4 -1
  134. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  135. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  136. data/lib/plutonium/definition/base.rb +1 -1
  137. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  138. data/lib/plutonium/helpers/turbo_helper.rb +30 -0
  139. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  140. data/lib/plutonium/resource/controller.rb +1 -0
  141. data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
  142. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  143. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  144. data/lib/plutonium/resource/policy.rb +7 -0
  145. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  146. data/lib/plutonium/ui/component/methods.rb +5 -0
  147. data/lib/plutonium/ui/form/base.rb +23 -3
  148. data/lib/plutonium/ui/form/components/json.rb +58 -0
  149. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  150. data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
  151. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  152. data/lib/plutonium/ui/form/interaction.rb +1 -1
  153. data/lib/plutonium/ui/form/resource.rb +0 -4
  154. data/lib/plutonium/ui/form/theme.rb +1 -1
  155. data/lib/plutonium/ui/grid/resource.rb +1 -1
  156. data/lib/plutonium/ui/layout/base.rb +1 -0
  157. data/lib/plutonium/ui/page/base.rb +0 -7
  158. data/lib/plutonium/ui/page/edit.rb +1 -1
  159. data/lib/plutonium/ui/page/index.rb +4 -4
  160. data/lib/plutonium/ui/page/new.rb +1 -1
  161. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  162. data/lib/plutonium/ui/table/resource.rb +1 -1
  163. data/lib/plutonium/version.rb +1 -1
  164. data/lib/plutonium.rb +8 -0
  165. data/lib/tasks/release.rake +15 -1
  166. data/package.json +13 -10
  167. data/src/css/slim_select.css +4 -0
  168. data/src/js/controllers/form_controller.js +5 -4
  169. data/src/js/controllers/slim_select_controller.js +61 -0
  170. data/src/js/turbo/turbo_actions.js +33 -0
  171. data/yarn.lock +661 -544
  172. metadata +86 -33
  173. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  174. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  175. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  176. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  177. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  178. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  179. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  180. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  181. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  182. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  183. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  184. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  185. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  186. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  187. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  188. data/docs/reference/assets/index.md +0 -496
  189. data/docs/reference/controller/index.md +0 -412
  190. data/docs/reference/definition/actions.md +0 -462
  191. data/docs/reference/definition/fields.md +0 -383
  192. data/docs/reference/definition/index.md +0 -326
  193. data/docs/reference/definition/query.md +0 -351
  194. data/docs/reference/generators/index.md +0 -648
  195. data/docs/reference/interaction/index.md +0 -449
  196. data/docs/reference/model/features.md +0 -248
  197. data/docs/reference/model/index.md +0 -218
  198. data/docs/reference/policy/index.md +0 -456
  199. data/docs/reference/portal/index.md +0 -379
  200. data/docs/reference/views/forms.md +0 -411
  201. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,368 @@
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
+ The default `param_key` derives from the entity class — `<singular_route_key>_scoped` — to avoid collisions with a `belongs_to :organization` on child models. So `scope_to_entity Organization` produces routes like `/organization_scoped/:organization_scoped_id/posts`. Override when you want a cleaner URL:
243
+
244
+ ```ruby
245
+ scope_to_entity Organization, strategy: :path, param_key: :org_id
246
+ # → /org_id/:org_id/posts
247
+ ```
248
+
249
+ Pair with `route_key:` to control the path segment as well:
250
+
251
+ ```ruby
252
+ scope_to_entity Organization, strategy: :path, param_key: :org_id, route_key: :orgs
253
+ # → /orgs/:org_id/posts
254
+ ```
255
+
256
+ ### Accessing the scoped entity
257
+
258
+ ```ruby
259
+ # Controller / views
260
+ current_scoped_entity # => current Organization
261
+ scoped_to_entity? # => true / false
262
+
263
+ # Policy
264
+ entity_scope # => current Organization
265
+ ```
266
+
267
+ ## Cross-tenant operations
268
+
269
+ ### Super-admin portal — no scoping
270
+
271
+ Create a separate portal without `scope_to_entity`:
272
+
273
+ ```ruby
274
+ module SuperAdminPortal
275
+ class Engine < Rails::Engine
276
+ include Plutonium::Portal::Engine
277
+
278
+ # No scope_to_entity — sees all tenants
279
+ end
280
+ end
281
+ ```
282
+
283
+ This portal's policies see everything. Don't enable public signup here.
284
+
285
+ ### Conditional scoping
286
+
287
+ ```ruby
288
+ class PostPolicy < ResourcePolicy
289
+ relation_scope do |relation|
290
+ return default_relation_scope(relation).where(category: :public) if user.guest?
291
+ default_relation_scope(relation)
292
+ end
293
+ end
294
+ ```
295
+
296
+ ## Multiple associations to the same entity class
297
+
298
+ Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises:
299
+
300
+ ```
301
+ Match has multiple associations to Competition::Team: home_team, away_team.
302
+ Plutonium cannot auto-detect which one to use for entity scoping.
303
+ Override `scoped_entity_association` in your controller to specify the association.
304
+ ```
305
+
306
+ Override on the controller:
307
+
308
+ ```ruby
309
+ class MatchesController < ::ResourceController
310
+ private
311
+ def scoped_entity_association = :home_team
312
+ end
313
+ ```
314
+
315
+ ## `param_key` differs from association name
316
+
317
+ Plutonium matches by **class**, not param key:
318
+
319
+ ```ruby
320
+ # Portal config
321
+ scope_to_entity Competition::Team, param_key: :team
322
+
323
+ # Model — association name differs from param_key, but Plutonium finds by class
324
+ class Match < ApplicationRecord
325
+ belongs_to :competition_team # ← Plutonium auto-detects this
326
+ end
327
+ ```
328
+
329
+ ## How the pieces fit together
330
+
331
+ 1. An admin opens `/organizations/42/projects`.
332
+ 2. Portal's `scope_to_entity Organization, strategy: :path` extracts `42`, loads the `Organization`, sets `current_scoped_entity`.
333
+ 3. The controller calls the policy. The policy's inherited `relation_scope` calls `default_relation_scope(relation)`.
334
+ 4. `default_relation_scope` has no parent (top-level nested-from-portal), so it calls `relation.associated_with(current_scoped_entity)`.
335
+ 5. `Project.associated_with(org)` resolves via the direct `belongs_to :organization` → `Project.where(organization: org)`.
336
+ 6. Only that organization's projects render. Records from other orgs are invisible.
337
+
338
+ Any model that can't be reached from the entity via these rules MUST declare a `has_one :through` or a custom scope.
339
+
340
+ ## Compound uniqueness
341
+
342
+ Always scope tenant-affecting uniqueness constraints:
343
+
344
+ ```ruby
345
+ class Property < ResourceRecord
346
+ belongs_to :organization
347
+ validates :code, uniqueness: {scope: :organization_id} # ← critical
348
+ end
349
+ ```
350
+
351
+ Without the scope, uniqueness leaks across tenants — Org A and Org B could collide on the same code.
352
+
353
+ ## Gotchas
354
+
355
+ - **Policy tries to filter by entity directly.** Wrong — bypasses `default_relation_scope`. Add the association path to the model instead.
356
+ - **`super` inside `relation_scope`.** Unreliable. Use `default_relation_scope(relation)` explicitly.
357
+ - **Multiple associations to the same entity class.** Override `scoped_entity_association`.
358
+ - **`param_key` differs from association name.** Fine — Plutonium finds the association by class.
359
+ - **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
360
+ - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly — never leave a `where` bypass in code.
361
+
362
+ ## Related
363
+
364
+ - [Nested resources](./nested-resources) — parent scoping takes precedence over entity scoping
365
+ - [Invites](./invites) — membership-based onboarding
366
+ - [Resource › Model](/reference/resource/model) — `associated_with`, model conventions
367
+ - [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
368
+ - [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!`. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super` to a parent policy (e.g., a package base) that calls it.
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