plutonium-behavior
Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
Install
mkdir -p .claude/skills/plutonium-behavior && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/14694" && unzip -o skill.zip -d .claude/skills/plutonium-behavior && rm skill.zipInstalls to .claude/skills/plutonium-behavior
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".About this skill
Plutonium Behavior — Controllers, Policies, Interactions
The behavior layer is intentionally thin: controllers route, policies authorize, interactions act. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to write the controller hook, policy method, or interaction class behind it.
For tenant-scoped relation_scope and entity scoping, load [[plutonium-tenancy]].
🚨 Critical (read first)
- Use generators.
pu:res:scaffoldcreates the base trio (controller/policy/interaction-base);pu:res:conncreates portal-specific versions. Never hand-write them. - Don't override CRUD actions. Use hooks (
resource_params,redirect_url_after_submit, presentation hooks). Overridingcreate/updateusually breaks authorization, params filtering, or both. create?andread?default tofalse. Always override them explicitly. Derived methods (update?,show?, etc.) inherit automatically.permitted_attributes_for_*must be explicit in production. Dev auto-detection works; production raises.ActiveRecord::RecordInvalidis NOT rescued automatically in interactions. Always rescue when usingcreate!/update!/save!, returnfailed(e.record.errors).- Return
succeed(...)orfailed(...)fromexecute— the controller can't tell what happened otherwise. - Redirect is automatic on success — only use
with_redirect_responsefor a different destination. relation_scopemust end up callingdefault_relation_scope(relation)somewhere in the chain. Prefer calling it explicitly.superworks when extending a parent policy (e.g., a package base) that itself calls it. See [[plutonium-tenancy]].- For
has_centsfields, use the virtual name (:price), not:price_centsinpermitted_attributes_for_*. - Custom action ⇒ policy method.
action :publishneedsdef publish?on the policy (undefined methods returnfalse). - Named custom routes. When adding custom routes, always pass
as:soresource_url_forcan build URLs.
🛑 Before you write behavior: place it in the right layer (ASK — don't infer)
"Make X happen" doesn't say where X lives. Put it in the wrong layer and you get authorization that doesn't authorize, a 500 on the happy path, or a CRUD override that breaks params/auth. First place the requirement, then confirm names against the real code (next section):
| The requirement (in plain words) | Goes in | NOT in |
|---|---|---|
| "only <role/owner> may do X" — who is allowed | Policy def x? | a condition: proc — that only hides the button; the route stays live and callable |
| "doing X changes state / sends mail / charges a card" — the work | Interaction execute, registered as an action | a hand-written controller action; an override of create/update |
| "after create/update go to Y" · "munge a param" · "reshape the index query" | Controller hook (redirect_url_after_submit, resource_params, filtered_resource_collection) | overriding create/update/index |
| "which fields are visible / editable" | Policy permitted_attributes_for_* | the definition — that only controls how a field renders |
Then resolve the specifics:
- A custom action needs BOTH: an interaction (the work) and a policy
def <action>?(the authorization). Miss the policy method ⇒ the action silently returnsfalse(dead button). Put the role check incondition:⇒ it isn't enforced — a direct POST still runs. create?/read?default tofalse— override explicitly; derived methods (update?/show?/…) inherit.- Any
create!/update!/save!inexecute⇒ rescueActiveRecord::RecordInvalid→failed(e.record.errors). Not auto-rescued — otherwise a validation failure 500s. has_cents⇒ permit:price, never:price_cents.- New vs editing — never re-scaffold a controller/policy/interaction that's been customized.
Never ship a guessed role method, column, enum value, or association as applied code. user.finance?, record.status_approved?, expense.submitted_by either exist in the app or they don't — confirm them before writing, don't assume. Fall back to AskUserQuestion only for genuine product choices (what the rule should be), never for facts you can read.
✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
You have file access — inspect; don't ask the user to describe their own app.
| Check | How | Why it matters |
|---|---|---|
| File already customized | Read app/policies/<x>_policy.rb, the controller, app/interactions/* | Edit incrementally — re-scaffolding clobbers customizations |
| The role/method you authorize on exists | grep the user model for def finance? / enum :role / has_role? | user.finance? 500s (or is silently false) if absent |
| The columns/enum your interaction writes | Read the model + db/schema.rb for the enum value, approved_by/approved_at, the submitter assoc | update!(status: :approved) raises if the value/column is missing |
| Action not already wired | grep the definition for action :<x>; grep the policy for def <x>? | Avoids duplicate or dead actions |
| Cross-resource access | Use authorized_resource_scope / allowed_to?, never raw where/find | Raw queries bypass the other resource's tenancy + visibility |
Inspect with your own tools before proposing code.
🛠 Use the generator — and know what's hand-authored
| Task | How | Verify first |
|---|---|---|
| Base trio (controller + policy + interaction-base) | pu:res:scaffold | New resource |
| Portal-specific controller/policy | pu:res:conn … --dest=portal | Resource exists |
| A custom-action interaction | Hand-author in app/interactions/<name>_interaction.rb (subclass ResourceInteraction) — there is NO pu:res:interaction generator; don't invent one | — |
| Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated — re-scaffolding clobbers it |
Part 1 — Controllers
Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
Base classes
# app/controllers/resource_controller.rb (installed once)
class ResourceController < ApplicationController
include Plutonium::Resource::Controller
end
# app/controllers/posts_controller.rb (per resource, generated by pu:res:scaffold)
class PostsController < ::ResourceController
# Empty — all CRUD inherited
end
What you get for free
| Action | Route | Purpose |
|---|---|---|
index | GET /posts | List with pagination, search, filters, sorting |
show | GET /posts/:id | Display single record |
new | GET /posts/new | Form |
create | POST /posts | Create |
edit | GET /posts/:id/edit | Form |
update | PATCH /posts/:id | Update |
destroy | DELETE /posts/:id | Delete |
Plus interactive-action routes for every action declared in the definition.
Where customization belongs
| Concern | Lives in |
|---|---|
| Field rendering (inputs, displays, columns) | Definition |
| Search, filters, scopes, sorting | Definition |
| Custom operations (publish, archive, import) | Interaction (+ action in definition) |
| Authorization rules | Policy |
| Form/show/page chrome | Definition (custom page classes) |
| Custom redirect logic | Controller hook |
| Param munging | Controller hook |
| Custom index query shape | Controller hook |
| Presentation of parent/entity fields | Controller hook |
Override hooks
All hooks are private methods. Override only the ones you need.
Redirect hooks
class PostsController < ::ResourceController
private
# Where to go after create/update: "show" (default), "edit", "new", "index"
def preferred_action_after_submit = "edit"
# Custom URL after create/update (overrides preferred_action_after_submit)
def redirect_url_after_submit = posts_path
# Custom URL after destroy
def redirect_url_after_destroy = posts_path
end
Parameter hook
def resource_params
params = super
params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
params
end
Index query hook
def filtered_resource_collection
base = current_authorized_scope
base = base.featured if params[:featured]
current_query_object.apply(base, raw_resource_query_params)
end
Presentation hooks
Control whether parent / scoped-entity fields appear in forms and displays. Defaults are false (hidden, since they're inferred from the URL/portal).
def present_parent? = true # show parent field on displays
def submit_parent? = true # include parent field in forms (default: tracks present_parent?)
def present_scoped_entity? = true
def submit_scoped_entity? = true
Custom actions
Prefer interactive actions (definition + interaction) for anything with business logic. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
class PostsController < ::ResourceController
def publish
authorize_current!(resource_record!, to: :publish?)
resource_record!.update!(published: true)
redirect_to resource_url_for(resource_record!), notice: "Published!"
end
end
Route must be named:
resources :posts do
member { post :publish, as: :publish } # `as:` required!
end
Key methods
Resource access
resource_class # The model class
resource_record! # Current record (raises if not found)
resource_record? # Current record (nil if not found)
resource_params # Permitted params for create/update
current_parent # Parent record for nested routes
current_scoped_entity # Tenant e
---
*Content truncated.*