plutonium 0.45.3 → 0.46.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +146 -0
  3. data/.claude/skills/plutonium-assets/SKILL.md +248 -157
  4. data/.claude/skills/{plutonium-rodauth → plutonium-auth}/SKILL.md +195 -229
  5. data/.claude/skills/plutonium-controller/SKILL.md +9 -2
  6. data/.claude/skills/plutonium-create-resource/SKILL.md +22 -1
  7. data/.claude/skills/plutonium-definition/SKILL.md +521 -7
  8. data/.claude/skills/plutonium-entity-scoping/SKILL.md +317 -0
  9. data/.claude/skills/plutonium-forms/SKILL.md +8 -1
  10. data/.claude/skills/plutonium-installation/SKILL.md +25 -2
  11. data/.claude/skills/plutonium-interaction/SKILL.md +9 -2
  12. data/.claude/skills/plutonium-invites/SKILL.md +11 -7
  13. data/.claude/skills/plutonium-model/SKILL.md +50 -50
  14. data/.claude/skills/plutonium-nested-resources/SKILL.md +8 -1
  15. data/.claude/skills/plutonium-package/SKILL.md +8 -1
  16. data/.claude/skills/plutonium-policy/SKILL.md +69 -78
  17. data/.claude/skills/plutonium-portal/SKILL.md +26 -70
  18. data/.claude/skills/plutonium-views/SKILL.md +9 -2
  19. data/CHANGELOG.md +28 -0
  20. data/app/assets/plutonium.css +1 -1
  21. data/app/views/rodauth/_login_form.html.erb +0 -3
  22. data/app/views/rodauth/confirm_password.html.erb +0 -4
  23. data/app/views/rodauth/create_account.html.erb +0 -3
  24. data/app/views/rodauth/logout.html.erb +0 -3
  25. data/docs/superpowers/plans/2026-04-08-plutonium-skills-overhaul.md +481 -0
  26. data/docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md +236 -0
  27. data/gemfiles/rails_7.gemfile.lock +1 -1
  28. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  30. data/lib/generators/pu/core/update/update_generator.rb +8 -0
  31. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +56 -0
  32. data/lib/generators/pu/invites/install_generator.rb +8 -1
  33. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +43 -0
  34. data/lib/generators/pu/profile/concerns/profile_arguments.rb +10 -4
  35. data/lib/generators/pu/profile/conn_generator.rb +9 -12
  36. data/lib/generators/pu/profile/install_generator.rb +5 -2
  37. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  38. data/lib/generators/pu/saas/portal_generator.rb +4 -9
  39. data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +2 -2
  40. data/lib/plutonium/engine.rb +18 -5
  41. data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -1
  42. data/lib/plutonium/version.rb +1 -1
  43. data/package.json +1 -1
  44. metadata +7 -8
  45. data/.claude/skills/plutonium/skill.md +0 -130
  46. data/.claude/skills/plutonium-definition-actions/SKILL.md +0 -424
  47. data/.claude/skills/plutonium-definition-query/SKILL.md +0 -364
  48. data/.claude/skills/plutonium-profile/SKILL.md +0 -276
  49. data/.claude/skills/plutonium-theming/SKILL.md +0 -424
@@ -0,0 +1,317 @@
1
+ ---
2
+ name: plutonium-entity-scoping
3
+ description: Use BEFORE writing relation_scope, associated_with, scoping a model to a tenant, or any multi-tenancy work. Also when configuring entity strategies on a portal. The single source of truth for Plutonium entity scoping.
4
+ ---
5
+
6
+ # Plutonium Entity Scoping
7
+
8
+ The single source of truth for how Plutonium scopes records to a tenant/entity in multi-tenant apps. Entity scoping spans models, policies, portals, and invites — this skill consolidates the canonical rules so you don't have to stitch them together from four other skills.
9
+
10
+ ## 🚨 Critical (read first)
11
+
12
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity skips Plutonium's scoping and triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly (not `super`).
13
+ - **Always declare an association path from the model to the entity.** If `associated_with` can't find a path — direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope — Plutonium raises. Fix the **model**, not the policy.
14
+ - **Use a generator to scaffold scoped resources.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, and `pu:res:scaffold` do the right thing. Hand-wiring scoping is how leaks happen.
15
+ - **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.
16
+ - **Related skills:** `plutonium-model` (associations, `associated_with`), `plutonium-policy` (`relation_scope` overrides), `plutonium-portal` (entity strategies), `plutonium-invites` (membership-backed scoping).
17
+
18
+ ## Quick checklist
19
+
20
+ Scoping a new model to a tenant:
21
+
22
+ 1. Pick the shape: direct child, join table, or grandchild (see [Three model shapes](#three-model-shapes)).
23
+ 2. Declare the association path on the model (`belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope).
24
+ 3. Verify `Model.associated_with(entity)` returns the right records in `rails runner`.
25
+ 4. Confirm the portal is scoped: `scope_to_entity Entity, strategy: :path` (or custom) in the portal engine.
26
+ 5. Leave `relation_scope` alone in the policy unless you need **extra** filters on top of the default.
27
+ 6. If you do override `relation_scope`, wrap with `default_relation_scope(relation).where(...)`.
28
+ 7. Add compound uniqueness scoped to the entity on the model (`validates :code, uniqueness: {scope: :organization_id}`).
29
+ 8. Test: create a record in org A, confirm it does NOT appear when scoped to org B.
30
+
31
+ ## How entity scoping works
32
+
33
+ Plutonium's entity scoping is built on three cooperating pieces:
34
+
35
+ - **Portal**: declares which entity class it scopes to (`scope_to_entity Organization, strategy: :path`) and how to resolve the current entity from the request.
36
+ - **Policy**: `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)`, applying the scope to every collection query.
37
+ - **Model**: `associated_with(entity)` resolves the scope via a custom scope, a direct association, or auto-detected `has_one :through` chain.
38
+
39
+ The `default_relation_scope` is enforced — if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
40
+
41
+ ## `associated_with` resolution
42
+
43
+ `Model.associated_with(entity)` resolves in this order:
44
+
45
+ 1. **Custom named scope** `associated_with_<model_name>` (e.g. `associated_with_organization`) — highest priority, full control over the SQL.
46
+ 2. **Direct `belongs_to` to the entity class** — `WHERE <entity>_id = ?`, most efficient.
47
+ 3. **`has_one` / `has_one :through` to the entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
48
+ 4. **Reverse `has_many` from the entity** — JOIN required, logs a warning (less efficient).
49
+
50
+ If none apply, raises:
51
+
52
+ ```
53
+ Could not resolve the association between 'Model' and 'Entity'
54
+ ```
55
+
56
+ with guidance to either add an association or define the custom scope.
57
+
58
+ ## `default_relation_scope` and safe `relation_scope` overrides
59
+
60
+ `default_relation_scope(relation)` does two things:
61
+
62
+ 1. If a **parent** is present (nested resource), scopes the relation via the parent association.
63
+ 2. Otherwise, applies `relation.associated_with(entity_scope)`.
64
+
65
+ ### Correct overrides
66
+
67
+ ```ruby
68
+ # ✅ Best: don't override at all — the inherited scope already calls default_relation_scope.
69
+
70
+ # ✅ Add extra filters on top of default scope
71
+ relation_scope do |relation|
72
+ default_relation_scope(relation).where(archived: false)
73
+ end
74
+
75
+ # ✅ Role-based extra filter
76
+ relation_scope do |relation|
77
+ relation = default_relation_scope(relation)
78
+ user.admin? ? relation : relation.where(author: user)
79
+ end
80
+ ```
81
+
82
+ ### Wrong overrides
83
+
84
+ ```ruby
85
+ # ❌ Manually filtering by the scoped entity — bypasses default_relation_scope
86
+ relation_scope do |relation|
87
+ relation.where(organization: current_scoped_entity)
88
+ end
89
+
90
+ # ❌ Manual joins — same problem
91
+ relation_scope do |relation|
92
+ relation.joins(:project).where(projects: {organization_id: current_scoped_entity.id})
93
+ end
94
+
95
+ # ❌ Missing default_relation_scope entirely — raises at runtime
96
+ relation_scope do |relation|
97
+ relation.where(published: true)
98
+ end
99
+ ```
100
+
101
+ **Do not rely on `super`** from inside `relation_scope do ... end`. `default_relation_scope` is the documented public contract; `super` semantics depend on how ActionPolicy's DSL registered the scope and aren't guaranteed.
102
+
103
+ ### Intentionally skipping the scope
104
+
105
+ Rare, but possible:
106
+
107
+ ```ruby
108
+ relation_scope do |relation|
109
+ skip_default_relation_scope!
110
+ relation
111
+ end
112
+ ```
113
+
114
+ Before reaching for this, consider a separate portal without scoping.
115
+
116
+ ## Entity strategies (portal configuration)
117
+
118
+ The portal declares how the current entity is resolved from the request.
119
+
120
+ ### Path strategy
121
+
122
+ ```ruby
123
+ module AdminPortal
124
+ class Engine < Rails::Engine
125
+ include Plutonium::Portal::Engine
126
+
127
+ config.after_initialize do
128
+ scope_to_entity Organization, strategy: :path
129
+ end
130
+ end
131
+ end
132
+ ```
133
+
134
+ Routes become `/organizations/:organization_id/posts`. The portal extracts `params[:organization_id]` and loads the entity automatically.
135
+
136
+ ### Custom strategy
137
+
138
+ ```ruby
139
+ module AdminPortal
140
+ class Engine < Rails::Engine
141
+ include Plutonium::Portal::Engine
142
+
143
+ config.after_initialize do
144
+ scope_to_entity Organization, strategy: :current_organization
145
+ end
146
+ end
147
+ end
148
+
149
+ module AdminPortal
150
+ module Concerns
151
+ module Controller
152
+ extend ActiveSupport::Concern
153
+ include Plutonium::Portal::Controller
154
+
155
+ private
156
+
157
+ def current_organization
158
+ @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ ```
164
+
165
+ The strategy symbol must match a method name on the controller.
166
+
167
+ ### Accessing the scoped entity
168
+
169
+ ```ruby
170
+ current_scoped_entity # => current Organization
171
+ scoped_to_entity? # => true/false
172
+ ```
173
+
174
+ Inside a policy, the same entity is available as `entity_scope`.
175
+
176
+ ## Three model shapes
177
+
178
+ The `associated_with` resolver handles three common model shapes. Pick the lightest one that fits.
179
+
180
+ ### Shape 1: Direct child (belongs_to the entity)
181
+
182
+ ```ruby
183
+ class Organization < ResourceRecord
184
+ has_many :projects
185
+ end
186
+
187
+ class Project < ResourceRecord
188
+ belongs_to :organization
189
+ end
190
+
191
+ # Usage
192
+ Project.associated_with(org)
193
+ # => Project.where(organization: org) # simple WHERE, most efficient
194
+ ```
195
+
196
+ **When to use:** the model naturally has a direct foreign key to the entity. No extra work; auto-detected.
197
+
198
+ ### Shape 2: Join table (membership-style)
199
+
200
+ A join table linking users to entities, where the entity is reachable via one of the `belongs_to`:
201
+
202
+ ```ruby
203
+ class User < ResourceRecord
204
+ has_many :memberships
205
+ has_many :organizations, through: :memberships
206
+ end
207
+
208
+ class Organization < ResourceRecord
209
+ has_many :memberships
210
+ has_many :users, through: :memberships
211
+ end
212
+
213
+ class Membership < ResourceRecord
214
+ belongs_to :user
215
+ belongs_to :organization
216
+
217
+ # ← auto-detection already finds :organization via belongs_to
218
+ end
219
+
220
+ # Usage
221
+ Membership.associated_with(org)
222
+ # => Membership.where(organization: org)
223
+ ```
224
+
225
+ **When to use:** a pure join table. The `belongs_to :organization` is sufficient.
226
+
227
+ If instead the join table is the scope target and you want to scope `Project` → `Membership` → `Organization`, add a `has_one :through`:
228
+
229
+ ```ruby
230
+ class ProjectMember < ResourceRecord
231
+ belongs_to :project
232
+ belongs_to :user
233
+ has_one :organization, through: :project # ← enables auto-scoping
234
+ end
235
+ ```
236
+
237
+ Now `ProjectMember.associated_with(org)` resolves via the `has_one :through` automatically.
238
+
239
+ ### Shape 3: Grandchild (multiple hops via `has_one :through`)
240
+
241
+ ```ruby
242
+ class Organization < ResourceRecord
243
+ has_many :projects
244
+ end
245
+
246
+ class Project < ResourceRecord
247
+ belongs_to :organization
248
+ has_many :tasks
249
+ end
250
+
251
+ class Task < ResourceRecord
252
+ belongs_to :project
253
+ has_one :organization, through: :project # ← critical line
254
+ end
255
+
256
+ # Deeper
257
+ class Comment < ResourceRecord
258
+ belongs_to :task
259
+ has_one :project, through: :task
260
+ has_one :organization, through: :project # ← enables auto-scoping
261
+ end
262
+
263
+ # Usage
264
+ Task.associated_with(org)
265
+ # => resolves via the :organization has_one :through
266
+
267
+ Comment.associated_with(org)
268
+ # => resolves via Comment -> Task -> Project -> Organization
269
+ ```
270
+
271
+ **When to use:** the model is two+ hops away from the entity. Declaring `has_one :organization, through: ...` is the **lightest fix** — `associated_with` finds it via `reflect_on_all_associations` with no policy override needed.
272
+
273
+ ### When to fall back to a custom scope
274
+
275
+ Use a custom `associated_with_<model_name>` scope when:
276
+
277
+ - The path is polymorphic.
278
+ - The path needs conditional logic.
279
+ - You want explicit SQL for performance (e.g. avoid a multi-join chain).
280
+
281
+ ```ruby
282
+ class Comment < ResourceRecord
283
+ scope :associated_with_organization, ->(org) do
284
+ joins(task: :project).where(projects: {organization_id: org.id})
285
+ end
286
+ end
287
+
288
+ # Plutonium picks this up BEFORE trying association detection.
289
+ ```
290
+
291
+ ## How the pieces fit together
292
+
293
+ 1. An admin opens `/organizations/42/projects`.
294
+ 2. Portal's `scope_to_entity Organization, strategy: :path` extracts `42`, loads the `Organization`, sets `current_scoped_entity`.
295
+ 3. The controller calls the policy. The policy's inherited `relation_scope` calls `default_relation_scope(relation)`.
296
+ 4. `default_relation_scope` has no parent (this is a top-level nested resource from the portal's perspective), so it calls `relation.associated_with(current_scoped_entity)`.
297
+ 5. `Project.associated_with(org)` resolves via the direct `belongs_to :organization` → `Project.where(organization: org)`.
298
+ 6. The controller renders only that organization's projects. Records from other orgs are invisible.
299
+
300
+ Any model that cannot be reached from the entity via these rules must declare a `has_one :through` or a custom scope. Policies must never work around this — work around it in the **model**.
301
+
302
+ ## Gotchas
303
+
304
+ - **Policy tries to filter by entity directly.** Wrong — that bypasses `default_relation_scope`. Add the association path to the model instead.
305
+ - **`super` inside `relation_scope`.** Unreliable. Call `default_relation_scope(relation)` explicitly.
306
+ - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one.
307
+ - **`param_key` differs from association name.** Fine — Plutonium finds the association by **class**, not param key. You can still `scope_to_entity Competition::Team, param_key: :team` and have the model use `belongs_to :competition_team`.
308
+ - **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks uniqueness across tenants. Use `validates :code, uniqueness: {scope: :organization_id}`.
309
+ - **Skipping the scope "temporarily" for debugging.** Use `skip_default_relation_scope!` explicitly — never leave a `where` bypass in the code.
310
+
311
+ ## Related skills
312
+
313
+ - `plutonium-model` — `associated_with` mechanics, declaring associations, `has_one :through` patterns.
314
+ - `plutonium-policy` — writing `relation_scope` safely, bulk authorization, attribute permissions.
315
+ - `plutonium-portal` — entity strategies (path, custom), `scope_to_entity`, mounting.
316
+ - `plutonium-invites` — how invites and memberships interact with entity scoping.
317
+ - `plutonium-nested-resources` — parent scoping semantics, which take precedence over entity scoping.
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  name: plutonium-forms
3
- description: Use when building custom form templates, overriding field builders, or theming form components in Plutonium
3
+ description: Use BEFORE customizing a form template, field builder, or input component in Plutonium. Also when overriding Form in a definition.
4
4
  ---
5
5
 
6
6
  # Plutonium Forms
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **Use `pu:field:input NAME` for custom input components.** Don't hand-write Phlexi field classes — the generator registers them correctly.
10
+ - **Configure inputs in the definition, render them in the form.** `input :foo, as: :markdown` in the definition; `render_resource_field :foo` in the custom form template.
11
+ - **Override via `class Form < Form` in the definition.** Don't replace the form root class.
12
+ - **`render_actions` renders the submit buttons.** Always call it at the end of a custom `form_template` or the form won't submit.
13
+ - **Related skills:** `plutonium-definition` (input configuration), `plutonium-views` (custom page classes), `plutonium-assets` (theming), `plutonium-interaction` (interaction forms).
14
+
8
15
  **Use generators for custom field types:**
9
16
  - `rails g pu:field:input NAME` creates a custom form input component
10
17
  - `rails g pu:field:renderer NAME` creates a custom display renderer
@@ -1,10 +1,31 @@
1
1
  ---
2
2
  name: plutonium-installation
3
- description: Use when installing Plutonium in a new or existing Rails app - generators, configuration, and initial setup
3
+ description: Use BEFORE installing Plutonium in a Rails app, running pu:core:install, or configuring initial Plutonium setup. Covers generators, gemfile, and initial config.
4
4
  ---
5
5
 
6
6
  # Plutonium Installation
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **Use the generators.** `pu:core:install`, `pu:rodauth:install`, `pu:pkg:portal`, `pu:res:scaffold`, `pu:res:conn` — never hand-write base controllers, policies, or layouts.
10
+ - **Use `base.rb`, not `plutonium.rb`, for existing apps.** The `plutonium.rb` template reruns the full bootstrap (dotenv, annotate, solid_*, assets) and clobbers git history. For any pre-existing app, use `base.rb`.
11
+ - **Pass `--dest`, `--force`, `--auth`, `--skip-bundle` for unattended runs** so generators don't block on prompts. See `plutonium` index for the full flag matrix.
12
+ - **Related skills:** `plutonium` (architecture overview), `plutonium-auth` (Rodauth setup), `plutonium-portal` (portal config), `plutonium-create-resource` (scaffolding resources).
13
+
14
+ ## Quick checklist
15
+
16
+ Fresh install in a new Rails app:
17
+
18
+ 1. Generate the Rails app with `rails new myapp -a propshaft -j esbuild -c tailwind -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb` (greenfield) OR `bin/rails app:template LOCATION=.../base.rb` (existing app).
19
+ 2. Run `bundle install` if you added the gem manually.
20
+ 3. Run `rails generate pu:core:install` to create base controllers, policies, definitions, and config.
21
+ 4. Run `rails generate pu:rodauth:install` + `rails generate pu:rodauth:account user` for auth.
22
+ 5. Run `rails generate pu:pkg:portal admin --auth=user` to create a portal.
23
+ 6. Run `rails generate pu:res:scaffold Post title:string 'content:text?' --dest=main_app` for a first resource.
24
+ 7. Run `rails db:migrate`.
25
+ 8. Run `rails generate pu:res:conn Post --dest=admin_portal` to connect the resource.
26
+ 9. Mount the portal in `config/routes.rb`: `mount AdminPortal::Engine, at: "/admin"`.
27
+ 10. Start the server and visit `/admin`.
28
+
8
29
  ## New Rails App (Recommended)
9
30
 
10
31
  Use the Rails template for a fully configured setup:
@@ -18,6 +39,8 @@ This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium in one co
18
39
 
19
40
  ## Existing Rails App
20
41
 
42
+ > **⚠️ Use `base.rb`, not `plutonium.rb`.** The `plutonium.rb` template is for `rails new` only — it re-runs the full app bootstrap (dotenv, annotate, solid_*, assets) and creates generic "initial commit" commits that clobber history. For any pre-existing app, always use `base.rb`.
43
+
21
44
  ### Option 1: Rails Template
22
45
 
23
46
  ```bash
@@ -293,7 +316,7 @@ For models that already exist in your app:
293
316
  ## Related Skills
294
317
 
295
318
  - `plutonium` - Resource architecture overview
296
- - `plutonium-rodauth` - Authentication setup and configuration
319
+ - `plutonium-auth` - Authentication setup and configuration
297
320
  - `plutonium-package` - Feature and portal packages
298
321
  - `plutonium-portal` - Portal configuration
299
322
  - `plutonium-views` - Custom pages, layouts, and Phlex components
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  name: plutonium-interaction
3
- description: Use when writing interaction classes for custom business logic, multi-step operations, or actions beyond basic CRUD
3
+ description: Use BEFORE writing an interaction class, encapsulating business logic, or building multi-step operations beyond basic CRUD. Covers Plutonium::Resource::Interaction.
4
4
  ---
5
5
 
6
6
  # Plutonium Interactions
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **`ActiveRecord::RecordInvalid` is NOT rescued automatically.** Always rescue it when using `create!`/`update!`/`save!` and return `failed(e.record.errors)`.
10
+ - **Return `succeed(...)` or `failed(...)`** from `execute` — the controller won't know what happened otherwise.
11
+ - **Redirect is automatic on success.** Only call `with_redirect_response` for a *different* destination.
12
+ - **Bulk actions use `resources` (plural).** Policy methods are checked per record; if any fails, the whole request fails.
13
+ - **Related skills:** `plutonium-definition` (registering actions), `plutonium-policy` (authorizing actions), `plutonium-forms` (interaction form templates).
14
+
8
15
  Interactions encapsulate business logic into reusable, testable units. They handle input validation, execution, and outcomes.
9
16
 
10
17
  ## Basic Structure
@@ -377,7 +384,7 @@ end
377
384
 
378
385
  ## Related Skills
379
386
 
380
- - `plutonium-definition-actions` - Declaring actions in definitions
387
+ - `plutonium-definition` - Declaring actions in definitions
381
388
  - `plutonium-forms` - Custom interaction form templates
382
389
  - `plutonium-policy` - Controlling access to actions
383
390
  - `plutonium` - How interactions fit in the architecture
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  name: plutonium-invites
3
- description: Use when setting up user invitations with entity membership in a multi-tenant Plutonium app
3
+ description: Use BEFORE setting up user invitations, pu:invites:install, or entity membership in a multi-tenant Plutonium app. Also load plutonium-entity-scoping.
4
4
  ---
5
5
 
6
6
  # Plutonium User Invites
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **Use the generators.** `pu:invites:install` and `pu:invites:invitable` — never hand-write invite models, mailers, or controllers. Prerequisites: user model, entity model, membership model (`pu:saas:setup` creates all three).
10
+ - **Invite email must match the accepting user's email.** This is a security feature. Don't disable `enforce_email?` unless you fully understand the implications.
11
+ - **Entity scoping applies to invites** — invites are automatically filtered by the current entity. See `plutonium-entity-scoping`.
12
+ - **Implement `on_invite_accepted` on invitable models.** Plutonium calls it when the invite is accepted; without it, the invitable never learns about the new user.
13
+ - **Related skills:** `plutonium-entity-scoping` (tenant scoping for invites), `plutonium-auth` (Rodauth signup flow), `plutonium-portal` (portal connection), `plutonium-interaction` (custom invite logic).
14
+
8
15
  Plutonium provides a complete user invitation system for multi-tenant applications. The system handles:
9
16
  - Sending email invitations to new users
10
17
  - Token-based invite acceptance flow
@@ -307,12 +314,9 @@ end
307
314
 
308
315
  ### Entity-Scoped Invite Management
309
316
 
310
- The `Invites::UserInvite` definition automatically scopes to the current entity:
317
+ Invites are automatically filtered by the current entity — admins only see invites for their organization. This works because `Invites::UserInvite` has `belongs_to :entity`, which `associated_with` picks up.
311
318
 
312
- ```ruby
313
- # In your portal, invites are automatically filtered by entity_scope
314
- # Admins only see invites for their organization
315
- ```
319
+ > **For how entity scoping works end-to-end (model shapes, `default_relation_scope`, portal strategies), see the [plutonium-entity-scoping](../plutonium-entity-scoping/SKILL.md) skill. It is the single source of truth.**
316
320
 
317
321
  ## Troubleshooting
318
322
 
@@ -357,7 +361,7 @@ end
357
361
 
358
362
  ## Related Skills
359
363
 
360
- - `plutonium-rodauth` - Authentication setup
364
+ - `plutonium-auth` - Authentication setup
361
365
  - `plutonium-interaction` - Custom business logic
362
366
  - `plutonium-portal` - Portal configuration
363
367
  - `plutonium-policy` - Authorization for invite actions
@@ -1,10 +1,30 @@
1
1
  ---
2
2
  name: plutonium-model
3
- description: Use when working with Plutonium resource models - setup, structure, has_cents, associations, SGID support, entity scoping, and routing
3
+ description: Use BEFORE editing a Plutonium resource model, adding associations, has_cents, SGID, or routing helpers. For tenancy / associated_with / relation_scope, also load plutonium-entity-scoping.
4
4
  ---
5
5
 
6
6
  # Plutonium Resource Models
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **Use `pu:res:scaffold`.** Never hand-write resource model files — the scaffold sets up `Plutonium::Resource::Record`, associations, and the expected section layout.
10
+ - **Declare associations for the entity.** For multi-tenant apps, add `belongs_to`, `has_one :through`, or an `associated_with_<entity>` scope so `associated_with` can resolve. Fix the model, not the policy.
11
+ - **Compound uniqueness** — in multi-tenant models, scope unique constraints to the tenant FK (`uniqueness: {scope: :organization_id}`), or you leak across tenants.
12
+ - **Keep business logic out of the model.** Use interactions for multi-step ops, policies for authorization.
13
+ - **Related skills:** `plutonium-entity-scoping` (tenancy mechanics), `plutonium-create-resource` (scaffold), `plutonium-definition` (UI), `plutonium-policy` (authorization).
14
+
15
+ ## Quick checklist
16
+
17
+ Adding/editing a Plutonium model:
18
+
19
+ 1. Use `pu:res:scaffold` for new models; include `Plutonium::Resource::Record` on existing ones.
20
+ 2. Place associations/enums/validations in the right section (enums → belongs_to → has_one → has_many → scopes → validations → callbacks).
21
+ 3. For monetary fields, use `has_cents :field_cents`.
22
+ 4. For multi-tenancy, declare an association path to the entity (`belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope).
23
+ 5. Add compound uniqueness scoped to the tenant FK.
24
+ 6. For SEO URLs, override `path_parameter` or `dynamic_path_parameter`.
25
+ 7. Override `to_label` if `:name`/`:title` isn't meaningful.
26
+ 8. Verify with `rails runner "puts Model.first.associated_with(entity).count"`.
27
+
8
28
  **Always use generators to create models** - never create model files manually:
9
29
  ```bash
10
30
  rails g pu:res:scaffold Post title:string content:text --dest=main_app
@@ -161,6 +181,31 @@ has_cents :field_cents,
161
181
  suffix: "amount" # Suffix for generated name (default: "amount")
162
182
  ```
163
183
 
184
+ ### Using `has_cents` fields in policies and definitions
185
+
186
+ **Always reference the virtual accessor (`:price`), never the underlying column (`:price_cents`).**
187
+
188
+ ```ruby
189
+ # Model
190
+ class Product < ResourceRecord
191
+ has_cents :price_cents # exposes virtual :price
192
+ end
193
+
194
+ # ✅ Policy — use the virtual name
195
+ class ProductPolicy < ResourcePolicy
196
+ def permitted_attributes_for_create
197
+ %i[name price] # NOT :price_cents
198
+ end
199
+ end
200
+
201
+ # ✅ Definition — use the virtual name
202
+ class ProductDefinition < ResourceDefinition
203
+ field :price, as: :decimal
204
+ end
205
+ ```
206
+
207
+ The virtual accessor handles form input, validation, and display as a decimal. Using `:price_cents` directly in a policy or definition forces users to enter integer cents and bypasses the conversion. Generators sometimes emit the `_cents` name in the policy — fix it by hand if you see it (and add `has_cents` if it's missing from the model).
208
+
164
209
  ### Validation
165
210
 
166
211
  ```ruby
@@ -233,64 +278,19 @@ user.remove_post_sgid("BAh7CEkiCG...") # Remove from collection
233
278
 
234
279
  ## Entity Scoping (associated_with)
235
280
 
236
- Query records associated with another record. Essential for multi-tenant apps.
281
+ `Plutonium::Resource::Record` provides `Model.associated_with(entity)` for multi-tenant queries. It resolves via a custom `associated_with_<entity>` scope, a direct `belongs_to`, or an auto-detected `has_one :through` chain.
237
282
 
238
- ### Basic Usage
283
+ Quick example:
239
284
 
240
285
  ```ruby
241
286
  class Comment < ResourceRecord
242
287
  belongs_to :post
243
288
  end
244
289
 
245
- # Find comments for a post
246
- Comment.associated_with(post)
247
- # => Comment.where(post: post)
290
+ Comment.associated_with(post) # => Comment.where(post: post)
248
291
  ```
249
292
 
250
- ### Association Detection
251
-
252
- Works with:
253
- - `belongs_to` - Uses WHERE clause (most efficient)
254
- - `has_one` - Uses JOIN + WHERE
255
- - `has_many` - Uses JOIN + WHERE
256
-
257
- ```ruby
258
- # Direct association (preferred)
259
- Comment.associated_with(post) # WHERE post_id = ?
260
-
261
- # Reverse association (less efficient, logs warning)
262
- Post.associated_with(comment) # JOIN comments WHERE comments.id = ?
263
- ```
264
-
265
- ### Custom Scopes
266
-
267
- For optimal performance, define custom scopes:
268
-
269
- ```ruby
270
- class Comment < ResourceRecord
271
- # Custom scope naming: associated_with_{model_name}
272
- scope :associated_with_user, ->(user) do
273
- joins(:post).where(posts: {user_id: user.id})
274
- end
275
- end
276
-
277
- # Automatically uses custom scope
278
- Comment.associated_with(user)
279
- ```
280
-
281
- ### Error Handling
282
-
283
- ```ruby
284
- # When no association exists
285
- UnrelatedModel.associated_with(user)
286
- # Raises: Could not resolve the association between 'UnrelatedModel' and 'User'
287
- #
288
- # Define:
289
- # 1. the associations between the models
290
- # 2. a named scope on UnrelatedModel e.g.
291
- #
292
- # scope :associated_with_user, ->(user) { do_something_here }
293
- ```
293
+ > **For entity scoping details — the three model shapes (direct child, join table, grandchild), `has_one :through` patterns, custom scopes, `default_relation_scope`, and how it fits with policies and portals — see the [plutonium-entity-scoping](../plutonium-entity-scoping/SKILL.md) skill. It is the single source of truth.**
294
294
 
295
295
  ## URL Routing
296
296
 
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  name: plutonium-nested-resources
3
- description: Use when configuring parent/child resource relationships, nested routes, or scoped URL generation in Plutonium
3
+ description: Use BEFORE configuring parent/child resource relationships, nested routes, or scoped URL generation with resource_url_for(parent:).
4
4
  ---
5
5
 
6
6
  # Nested Resources
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **Use `pu:res:scaffold` + `pu:res:conn`** for both parent and child. Nested routes are generated from the `belongs_to` + association on the parent — no manual route wiring.
10
+ - **Parent scoping beats entity scoping.** When a parent is present, `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope in the policy.
11
+ - **Plutonium supports one level of nesting.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
12
+ - **Named custom routes only.** When adding member/collection routes on a nested resource, always pass `as:` — otherwise `resource_url_for` will fail.
13
+ - **Related skills:** `plutonium-entity-scoping` (how parent scoping interacts with entity scoping), `plutonium-policy` (parent scoping in `relation_scope`), `plutonium-controller` (presentation hooks), `plutonium-portal` (route registration).
14
+
8
15
  **Always use generators** to create both parent and child resources, then connect them to portals:
9
16
  ```bash
10
17
  rails g pu:res:scaffold Company name:string --dest=main_app
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  name: plutonium-package
3
- description: Use when creating feature packages or portal packages to organize a Plutonium app into modular engines
3
+ description: Use BEFORE creating a feature package or portal package via pu:pkg:package / pu:pkg:portal, or organizing a Plutonium app into modular engines.
4
4
  ---
5
5
 
6
6
  # Plutonium Packages
7
7
 
8
+ ## 🚨 Critical (read first)
9
+ - **Use the generators.** `pu:pkg:package` for feature packages, `pu:pkg:portal` for portal packages — never hand-write engine files or directory structures.
10
+ - **Feature vs portal is a hard split.** Feature packages hold models/policies/definitions/interactions; portal packages hold controllers/views/routes/auth. Don't mix.
11
+ - **Package classes are auto-namespaced** (`packages/blogging/app/models/blogging/post.rb` → `Blogging::Post`). Don't fight the namespacing.
12
+ - **Cross-package resource references** use the full namespace: `rails g pu:res:conn Blogging::Post --dest=admin_portal`.
13
+ - **Related skills:** `plutonium-portal` (portal-specific features), `plutonium-create-resource` (creating resources in packages), `plutonium-installation` (package loading).
14
+
8
15
  Packages are specialized Rails engines for organizing code. There are two types:
9
16
 
10
17
  | Type | Purpose | Generator |