Use when creating, editing, or reviewing Discourse service objects that include Service::Base - covers contracts, models, policies, steps, transactions, controller integration, and service specs
We want high quality code and very senior engineering work. Best oriented object practices are observed. Think principles like SOLID and battle-tested patterns. We also want to write idiomatic ruby. Good reference authors are Sandi Metz, Katrina Owen or Avdi Grimm. Your only source of truth to write services is the documentation at docs/developer-guides/docs/03-code-internals/19-service-objects.md, don't look at examples in the codebase.
DONT USE ANY WRITING PLAN SKILL DURING THE SESSION
When the user says "autonomous mode" (or similar), apply these overrides:
MANDATORY: At the start of every session using this skill, create a task for each phase using TaskCreate:
Create ALL tasks upfront before starting any work. Mark each task as in_progress when you begin it and completed when the user approves the phase gate.
Every phase is a discrete step. Complete one phase fully before starting the next. Each phase ends with a user-visible deliverable — findings, an audit table, or a confirmation. NEVER silently advance to the next phase.
Review phases (4, 5, 6) use a strict audit loop:
Audit table format (used by phases 4, 5, 6):
| # | Rule | Verdict | Evidence |
|---|------|---------|----------|
| 1 | Service name describes core business concept | PASS | `Chat::Message::Trash` — uses domain vocabulary |
| 2 | Steps have one concern each | FAIL | `update_message` both validates and persists |
| 3 | No utility methods in service | NA | No utility methods present |
Build a thorough understanding of the codepath being refactored. Read every file involved: the controller action, the class method or inline logic being extracted, all models touched, guardian/policy extensions, serializers, routes, and every caller (production code, specs, import scripts, dev tools, other plugins).
Gate: Present a summary to the user listing every file read and the key responsibilities discovered. Wait for user confirmation that the understanding is complete before proceeding to Phase 2.
Surface gaps, architectural decisions, and issues for user approval before writing any code.
-> { with_deleted } scope to belongs_to associations that may reference soft-deleted records.Present all findings to the user and get approval on scope before proceeding.
Write the service, controller, update all callers, and remove dead code.
before blocks with any site settings the service's policy requires.Gate: Present a summary of all files created, modified, and deleted. Wait for user acknowledgment before proceeding to Phase 4.
Review the service against these structural rules. List every violation found.
revoke_previous_accepted_answer not destroy_old_record). ALWAYS use Discourse core domain vocabulary. Ask "why is this happening?" not "what ActiveRecord method am I calling?"post_id not id, channel_id not targetcontext[:] is a code smell. Verify a model, options, or step keyword argument cannot achieve the same result. Refactoring should almost always let you use a model instead.transaction wraps ONLY DB writes that must succeed or fail together. Side effects (webhooks, events, MessageBus) live outside.lock wraps ONLY steps vulnerable to concurrent modification. Side effects are idempotent and MUST live outside the lock.return if/return unless at the top of step methods — use only_if wrappers. Guard clauses in only_if predicate methods use bare return (not return false).before_actioncreate over new + separate save! in model stepsonly_if wrapper — never bundle conditional side effects with internal if-statementstry — trust internal bang methods, use non-bang persistence in model stepssecure_audience, ALL publishes on that channel mustfetch_ prefix)only_if, ask whether the else branch is truly conditional or default behavior that should always runtarget_post: record) over foreign-key lookupsskip_policy optionsApplicationSerializer — never use plain classes with class-method .serialize() patternsinclude_*? methods for conditional attributes, not if/else hash buildingCardTopicSerializer)MySerializer.new(object, root: false).as_json — never hide them behind controller helper methodsMySerializer.new(object, root: false, my_data:).as_json accessed via @options[:my_data]When in doubt AskUserQuestion.
Audit loop:
Trace every code path in the original implementation against the new service. List every bug found by criticality.
Audit loop:
Review for security concerns. List every issue found by criticality.
user.moderator? / guardian.is_moderator? alone. Verify whether category moderators for the specific category should be allowed, and whether the actor can see or act on that exact resource via an existing guardian/policy method or guardian.is_category_group_moderator?(category) plus the relevant visibility check.Audit loop:
MANDATORY: Run this entire phase in a subagent. Use the Agent tool to spawn a dedicated agent for writing and running specs. Pass it the service file path, the spec file path, and the full checklist below. Do NOT write specs in the main conversation.
Two authoritative references govern how specs are written. Whenever you are unsure about an RSpec pattern, naming convention, matcher usage, or structural rule, fetch the relevant section before writing code:
RSpec Style Guide — https://rspec.rubystyle.guide Fetch this page and search for the keyword you need guidance on (e.g. "subject", "context", "let", "shared examples", "named subject", "aggregate_failures", "one expectation"). Use it as the definitive authority on RSpec idioms and style.
Service documentation — docs/developer-guides/docs/03-code-internals/19-service-objects.md, section Testing
This is the definitive authority on testing Discourse services: structure, custom matchers, and conventions. Every spec must follow the patterns shown there.
describe described_class::Contract, type: :model block with shoulda matchersdescribe ".call" block for the service executionsubject(:result) { described_class.call(params:, **dependencies) } as the first declaration in the .call block — the subject must be shared and parameterizable across contextsfab!, params as let(:params), dependencies as let(:dependencies) — order: subject, fab!, let, beforecontext per possible branching point, following the step order defined in the servicefail_a_contract, fail_to_find_a_model(:name), fail_a_policy(:name), fail_with_an_invalid_model(:name), fail_with_exception, fail_a_step(:name), run_successfully.call block — the contract is tested above; the .call context only needs one example proving the step halts execution (e.g. one invalid value)run_successfully and then tests side effects (DB changes, events, logs)let values in nested contexts to trigger each failure branch — NEVER duplicate the subject callfab! in nested contexts to change actors (e.g., fab!(:acting_user, :admin)) rather than overriding let(:guardian)change, eq, include, be_empty, predicate matchers like be_published) — never bare be or vague assertionsDiscourseEvent.track_events(:event_name) { result } to test event triggers — NEVER manual on/offlet(:messages) { MessageBus.track_publish(channel) { result } } as a lazy letModel.create!:topic_with_op fabricator when the topic needs an OP post for validations to passAudit loop:
Run the full plugin spec suite and any cross-plugin specs that touch the refactored code. Lint all changed files. Verify everything is green before presenting the completed work.