plutonium 0.50.0 → 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 (132) 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 +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -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 +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -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