Skip to content

PACE with Ruby (Rails)

This tutorial shows how to configure PACE for a Ruby on Rails project. FORGE will write idiomatic Ruby following Rails conventions, GATE will run RSpec or Minitest, and SENTINEL will audit for Rails-specific security risks. By the end you will have a working pace.config.yaml, a three-story sprint plan, and a clear picture of how FORGE, GATE, and SENTINEL interact with a Rails codebase.

Prerequisites

  • Ruby 3.2 or later
  • Rails 7.1+
  • RSpec or Minitest
  • An API key for your LLM provider

Project layout assumed by this tutorial

my-api/
├── pace/
│ └── pace.config.yaml
├── app/
│ ├── controllers/
│ ├── models/
│ └── services/
├── spec/ ← or test/ for Minitest
├── Gemfile
└── .pace/

1 — Install PACE

Terminal window
# From your repo root
git clone https://github.com/pace-framework-org/pace-framework-starter pace
cd pace
python -m venv .venv && source .venv/bin/activate
pip install PyYAML jsonschema anthropic

2 — Configure for Ruby

Edit pace/pace.config.yaml:

framework_version: "1.0"
product:
name: "My API"
description: >
A Ruby on Rails 7.1 REST API. Service objects live in app/services/;
controllers are thin and delegate immediately to services. Business logic
never lives in models. Uses RSpec with FactoryBot for test fixtures and
DatabaseCleaner with the transaction strategy for fast test isolation.
All REST routes are resourceful. Concerns are used for shared model behaviour.
github_org: "my-org"
sprint:
duration_days: 14
source:
dirs:
- name: "app"
path: "app/"
language: "Ruby"
description: "Rails controllers, models, and service objects"
- name: "spec"
path: "spec/"
language: "Ruby"
description: "RSpec unit and request specs"
tech:
primary_language: "Ruby 3.2 / Rails 7.1"
ci_system: "GitHub Actions"
test_command: "bundle exec rspec --format progress"
build_command: "bundle exec rubocop --no-color -f quiet"
platform:
ci: local
llm:
provider: anthropic
model: claude-sonnet-4-6

RSpec vs Minitest variants

Setuptest_command
RSpec (default)bundle exec rspec --format progress
RSpec (specific directory)bundle exec rspec spec/services/ --format progress
Minitestbundle exec rails test
Minitest (specific directory)bundle exec rails test test/services/
With SimpleCovCOVERAGE=true bundle exec rspec

3 — FORGE hints for Ruby

Describe your conventions in product.description. FORGE reads this before writing any file.

Effective hints for Rails projects:

product:
description: >
Service objects in app/services/. Controllers call services; services call models.
Use RSpec with FactoryBot for test fixtures. Never use fixtures/ files.
All RSpec tests in spec/. Unit tests in spec/services/. Request specs in spec/requests/.
Use DatabaseCleaner with transaction strategy for fast test isolation.
Follow Rails conventions: snake_case, RESTful routes, concerns for shared model behavior.
Use frozen_string_literal: true in all Ruby files.

4 — Set credentials

Terminal window
export ANTHROPIC_API_KEY="sk-ant-..."

5 — Write a Ruby sprint plan

Create plan.yaml at your repo root:

release: v1.0
stories:
- id: story-1
title: "Subscription model, migration, and repository service"
status: pending
acceptance_criteria:
- "Subscription model has user_id, plan, and status columns"
- "Migration creates the subscriptions table with correct types and indexes"
- "SubscriptionRepository service wraps ActiveRecord with create, find, and list_by_user methods"
- "RSpec tests cover create, find by id, and list_by_user filtering"
- "bundle exec rspec exits 0"
out_of_scope:
- "HTTP endpoints"
- "Billing integration"
- id: story-2
title: "SubscriptionUpgradeService with business rules"
status: pending
acceptance_criteria:
- "call() raises InvalidUpgradeError when downgrading to a lower plan tier"
- "call() raises SubscriptionNotFoundError for an unknown subscription id"
- "RSpec tests use instance_double(SubscriptionRepository) — no real database"
- "bundle exec rspec exits 0"
- id: story-3
title: "POST /subscriptions and GET /subscriptions/:id endpoints"
status: pending
acceptance_criteria:
- "POST /subscriptions returns 201 with subscription JSON on success"
- "GET /subscriptions/:id returns 200 with subscription JSON or 404 if not found"
- "Request specs use the Rails request spec pattern (no controller spec)"
- "bundle exec rspec exits 0"

6 — Run Day 1

Terminal window
cd pace
python pace/orchestrator.py --day 1

What FORGE does with Ruby

FORGE’s tool-calling loop will:

  1. Read Gemfile and Gemfile.lock to understand dependencies and Rails version
  2. Read existing app/ and spec/ files to learn patterns, naming conventions, and service structure
  3. Write .rb files using Rails conventions, ActiveRecord, and RSpec
  4. Run bundle exec rubocop (build_command) to catch style violations
  5. Run bundle exec rspec and read failure output to self-correct

A typical FORGE run for Day 1:

[FORGE] Reading Gemfile ...
[FORGE] Reading Gemfile.lock ...
[FORGE] Reading app/models/ (directory listing) ...
[FORGE] Reading spec/spec_helper.rb ...
[FORGE] Writing db/migrate/20240101000000_create_subscriptions.rb ...
[FORGE] Writing app/models/subscription.rb ...
[FORGE] Writing app/services/subscription_repository.rb ...
[FORGE] Writing spec/services/subscription_repository_spec.rb ...
[FORGE] Running build: bundle exec rubocop --no-color -f quiet ...
[FORGE] Running tests: bundle exec rspec --format progress ...
[FORGE] All tests pass. Calling complete_handoff.

What SENTINEL checks for Ruby

SENTINEL applies Rails-specific security checks:

  • SQL injection: string interpolation in ActiveRecord queries (e.g. where("name = '#{params[:name]}'")) instead of parameterized form
  • Mass assignment: params.permit(:all) or missing strong parameters in controllers
  • Secrets: credentials hardcoded in config/database.yml or .env files committed to git
  • Unsafe YAML: YAML.load with user input (use YAML.safe_load instead)
  • Command injection: backtick execution or system() with user-controlled input
  • Authentication: missing before_action :authenticate_user! on sensitive controllers
  • Insecure redirect: redirect_to params[:url] or similar without URL allowlist validation

7 — Test coverage

Use SimpleCov to enforce a coverage minimum. Add this to the top of spec/spec_helper.rb (before any other require):

spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
minimum_coverage 80
add_filter '/spec/'
end

SimpleCov exits non-zero when coverage drops below the minimum — GATE will issue a HOLD and FORGE will write additional tests until the threshold is met.

8 — Linter and formatter

RuboCop is the standard Ruby linter. A minimal .rubocop.yml:

.rubocop.yml
AllCops:
TargetRubyVersion: 3.2
NewCops: enable
Style/Documentation:
Enabled: false

Set the corresponding build_command:

tech:
build_command: "bundle exec rubocop --no-color -f quiet"

Add a lint hint to product.description so FORGE writes conforming code on the first attempt:

product:
description: >
Code must pass RuboCop. Max method length 20 lines.
Use frozen_string_literal: true in all files.

9 — Database testing patterns

SQLite in-memory (fast unit tests)

For projects where integration test fidelity matters less than speed, configure the test environment to use SQLite in memory:

config/database.yml
test:
adapter: sqlite3
database: ":memory:"

DatabaseCleaner for test isolation

spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end

Require it in spec/spec_helper.rb:

require 'support/database_cleaner'

Tell FORGE about the strategy in product.description:

product:
description: >
Tests use DatabaseCleaner with transaction strategy. Never use truncation — it is too slow.
System specs that require JavaScript use the truncation strategy only.

10 — Mock patterns

describe SubscriptionUpgradeService do
let(:repo) { instance_double(SubscriptionRepository) }
let(:service) { described_class.new(repository: repo) }
it 'raises when subscription not found' do
allow(repo).to receive(:find).and_return(nil)
expect { service.call(id: 'x', plan: :pro) }
.to raise_error(SubscriptionNotFoundError)
end
end

Add a hint to product.description so FORGE never uses unverified doubles:

product:
description: >
Use instance_double() for mocking, not double().
Always verify doubles match the real interface.
Inject dependencies via the constructor keyword argument repository:.

11 — Multi-day sprint

After Day 1 completes, SCRIBE writes a summary to .pace/context/engineering.md. When Day 2 starts, FORGE reads it before writing any new code:

[FORGE] Reading .pace/context/engineering.md ...
[FORGE] Found: app/models/subscription.rb (Subscription, SubscriptionStatus, Plan)
[FORGE] Found: app/services/subscription_repository.rb (SubscriptionRepository)
[FORGE] Writing app/services/subscription_upgrade_service.rb ...
[FORGE] Writing spec/services/subscription_upgrade_service_spec.rb ...

This means story-2 acceptance criteria can safely reference classes defined in story-1 — FORGE knows they exist and requires them correctly.

12 — Advisory clearance story

SENTINEL advisories that are not auto-fixed generate a clearance story in your backlog. Common Ruby advisories:

- id: story-4
title: "Address SENTINEL advisories from Day 1"
status: pending
acceptance_criteria:
- "Replace YAML.load calls with YAML.safe_load throughout the codebase"
- "Add strong parameters (permit) to any controller action missing them"
- "Replace params.permit(:all) with an explicit permit list in SubscriptionsController"
- "bundle exec rspec exits 0"

13 — CI build caching

The ruby/setup-ruby action handles gem installation and caching with a single option:

- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true # runs bundle install and caches gems automatically

bundler-cache: true runs bundle install and caches the installed gems keyed to your Gemfile.lock. Subsequent runs restore from cache and skip installation entirely when the lockfile has not changed.

14 — Windows notes

Ruby on Rails runs on Windows, but the smoothest experience is through WSL2. If running natively:

  • bundle exec handles the PATH correctly — you do not need to invoke ruby directly.
  • Test command paths use forward slashes: bundle exec rspec spec/services/ works on Windows.
  • If you encounter encoding errors, set RUBYOPT=-Eutf-8 in your shell before running PACE.

For the best experience on Windows, use WSL2 with Ubuntu and follow the standard Linux instructions.

Common issues

bundle: command not found Ensure Bundler is installed (gem install bundler) and a Gemfile is present at the repo root. If you manage Ruby versions with rbenv or rvm, ensure the correct version is activated before running PACE.

FORGE writes files to the wrong Rails path Describe your non-standard structure explicitly in source.dirs descriptions. For example: "Service objects in app/services/. POROs are not in app/models/." Explicit path descriptions override FORGE’s default Rails assumptions.

RuboCop fails on FORGE-generated code Generate a RuboCop baseline for pre-existing violations (bundle exec rubocop --auto-gen-config). FORGE writes new code clean — the baseline silences only violations that existed before PACE was introduced.

DatabaseCleaner truncates too slowly Switch to the :transaction strategy if you have not already. Reserve :truncation for system specs that open a real browser, which cannot share a transaction with the test process. See section 9 for setup.

FactoryBot factory not found Ensure require 'factory_bot_rails' is in spec/spec_helper.rb (or rails_helper.rb) and that your factories are in spec/factories/. If you use a non-standard factory directory, add FactoryBot.definition_file_paths = ["path/to/factories"] and note the path in product.description.