Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ inherit_gem:
rubocop-rails-omakase: rubocop.yml

AllCops:
TargetRubyVersion: 3.1
TargetRubyVersion: 3.2
Exclude:
- "test/dummy/**/*"
- "tmp/**/*"
Expand Down
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# AGENTS.md

Mountable Rails engine, published on rubygems as `approval_engine`. Ruby >= 3.2,
PostgreSQL only (jsonb, gin, `gen_random_uuid()`).

## Commands

Every command below was run against this repo. `bin/rails` drives the throwaway
host app in `test/dummy/`, which is why engine tasks carry the `app:` prefix.

- Install + prepare DB: `bin/setup` (set `DATABASE_URL` if Postgres isn't on the default socket)
- Test (all): `bin/rails app:test`
- Test (one file): `bin/rails app:test TEST=test/models/approval_engine/step_test.rb`
- Test (one case): `bin/rails app:test TEST=<file> TESTOPTS="-n=/percentage/"`
- Coverage: `COVERAGE=1 bin/rails app:test` → `coverage/index.html`
- Lint: `bundle exec rubocop` (`-a` to autocorrect)
- Package smoke test: `gem build approval_engine.gemspec`
- REPL: `bin/console` · Dashboard with seed data: `bin/demo`

There is no type checker. Lint and tests are the whole gate, and CI runs exactly
those two across Ruby 3.2–3.4 / Rails 7.1–8.x.

## Structure

- `app/models/approval_engine/` — the append-only ledger: `Approval` → `Track` → `Step`, plus value objects (`Consensus`) and shared concerns.
- `app/services/approval_engine/` — things that build or decide: `ApprovalBuilder`, `FlowDefinition` (the `define_flow` DSL), `RuleEvaluator`.
- `lib/approval_engine/` — the host-facing surface: `Configuration`, `Condition`, `TestHelpers`. Changing a name here is a breaking change for consumers.
- `lib/generators/**/templates/` — copied verbatim into host apps, never executed here (and filtered out of coverage). Don't expect tests to cover them.
- `test/dummy/` — the host app the suite boots. Not product; rubocop ignores it.

## Conventions

- **Mechanism, not policy.** The engine owns the generic, dangerous parts; the host owns business logic through seams (config callables, model callbacks, the DSL). New features preserve that boundary.
- **Rich models over service objects.** Prefer ActiveRecord behaviour and bang methods to procedural managers.
- Before writing a helper, grep for one. Three models already share `ConsensusValidatable` and `Outboxable`; a fourth copy of a validation or an outbox write belongs in a concern, not in the model.
- Same name, different job is normal here — `guard_consensus!` and `actor_label` each exist twice on purpose. Read both before "de-duplicating" them.
- Style is whatever `rubocop-rails-omakase` says.

## Gotchas

- **A stale `test/dummy/db/schema.rb` fails the entire suite** with `UnknownAttributeError` / `UndefinedTable` — errors that look like broken code but mean a stale database. Rails' `maintain_test_schema` loads that dump over your migrations. Fix: `rm test/dummy/db/schema.rb && bin/setup`. The file is gitignored and regenerated.
- Engine and dummy migrations interleave by timestamp but run from separate paths, so the engine needs **two** passes. `bin/setup` and CI both do this; a single `app:db:migrate` can leave you short.
- **`test/docs_test.rb` treats the README and CHANGELOG as API.** It asserts every `config.*` key, `ApprovalEngine.*` call and `rails generate` line in the README really exists, that README images are absolute URLs that resolve in-repo, and that every CHANGELOG version heading has a link reference. Rename a config key without updating the README and the suite fails — that is the test doing its job.
- Adding a config key means three places: `Configuration`, the install template at `lib/generators/approval_engine/install/templates/approval_engine.rb`, and the README.
- `db/migrate/` is excluded from rubocop and kept verbatim. Don't reformat it.
- `Step#transition!` is the chokepoint for every human decision: it takes a pessimistic lock on the approval, re-checks `pending?`, then writes the status, the audit row and the outbox event together. `approve!` / `reject!` / `request_changes!` all route through it. Keep that atomicity — the outbox is the only thing stopping a failing host callback from rolling back an approval.

## Working on an issue

1. Reproduce with a failing test in the existing test file for that module.
2. Fix with minimal churn; reuse existing helpers.
3. `bundle exec rubocop` and `bin/rails app:test` must both be green.
4. Add a `CHANGELOG.md` entry under "Unreleased" (create the section if absent).
5. Branch `fix/<short-desc>` or `feat/<short-desc>`; conventional commit message.
6. PR description: root cause, what changed, tests added.
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Housekeeping only — no behaviour change, no public API change.

### Fixed

- `bin/setup` failed on a fresh clone. It ran `app:db:test:prepare`, which loads
`test/dummy/db/schema.rb` — a gitignored file that does not exist until
something has migrated. It now migrates instead, mirroring CI: create, engine
pass, dummy pass, engine pass again for the engine migrations that sort after
the dummy's. The clone-to-green path in CONTRIBUTING.md now works as written.
- `CONTRIBUTING.md` told contributors Ruby 3.1 while the gemspec has required
`>= 3.2.0` since 1.0 — anyone who believed it could not `bundle install`.
`.rubocop.yml` targeted 3.1 for the same reason and is now at 3.2.

### Changed

- The `approvals_required` validation lived in `Approval`, `Step` and
`TemplateStep` as three byte-identical copies, error string included; it is now
the `ConsensusValidatable` concern. `emit_outbox` likewise moves to
`Outboxable` — its two copies had already drifted in signature.
- Added `AGENTS.md` (with `CLAUDE.md` importing it) so a coding agent has the
verified command set and the repo's traps without rediscovering them. Neither
file ships in the gem.

## [1.1.0] - 2026-08-29

Three things 1.0 left you to build: authoring a flow, editing routing rules in a
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ clone to a green test run.

## Getting started

ApprovalEngine is a Rails engine. It needs **Ruby 3.1+** and **PostgreSQL**.
ApprovalEngine is a Rails engine. It needs **Ruby 3.2+** and **PostgreSQL**.

```sh
git clone https://github.com/Harry-kp/approval_engine
Expand Down Expand Up @@ -54,5 +54,7 @@ bin/demo # boots the dashboard with seeded data
- **Rich models over service objects.** Prefer ActiveRecord behaviour and bang
methods to procedural managers.
- Follow the surrounding style; `rubocop-rails-omakase` is the source of truth.
- `AGENTS.md` holds the same commands and gotchas in the form a coding agent
reads. If you change a command or hit a trap worth warning about, update it.

By contributing, you agree your work is licensed under the project's MIT License.
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ if (rails_version = ENV["RAILS_VERSION"]) && !rails_version.empty?
gem "rails", "~> #{rails_version}.0"
# Rails 7.x predates Minitest 6, whose runner signature it calls incorrectly.
gem "minitest", "~> 5.25" if rails_version.start_with?("7.")
# ActiveSupport's JSON encoder passes `quirks_mode:` to JSON.generate up to
# Rails 8.0; json 3 removed the keyword, so every migration raises
# `ArgumentError: unknown keyword: quirks_mode` before a test can run. Fixed
# in 7.2 and 8.1+, which is why the unpinned matrix entry is unaffected. Drop
# this once 7.1/8.0 leave the support range.
gem "json", "< 3" if rails_version.start_with?("7.1", "8.0")
end

gem "puma"
Expand Down
19 changes: 3 additions & 16 deletions app/models/approval_engine/approval.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ module ApprovalEngine
# `approvals_required` (`:all` by default, like a layer). Progression methods
# run while the approval row is locked by the acting step, so they don't relock.
class Approval < ApplicationRecord
include ConsensusValidatable
include Outboxable

STATUSES = %w[pending approved rejected quarantined cancelled].freeze
TERMINAL_STATUSES = %w[approved rejected quarantined cancelled].freeze

Expand All @@ -20,7 +23,6 @@ class Approval < ApplicationRecord

validates :tenant_id, presence: true
validates :status, inclusion: { in: STATUSES }
validate :approvals_required_is_valid

scope :pending, -> { where(status: "pending") }
scope :quarantined, -> { where(status: "quarantined") }
Expand Down Expand Up @@ -117,12 +119,6 @@ def track_outcome
end
end

def approvals_required_is_valid
return if Consensus.valid?(approvals_required)

errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
end

def cancel_remaining_tracks!
tracks.where(status: %w[pending]).find_each do |track|
track.steps.where(status: Track::OPEN_STEP_STATUSES).find_each do |step|
Expand All @@ -131,14 +127,5 @@ def cancel_remaining_tracks!
track.update!(status: "cancelled")
end
end

def emit_outbox(event_name, reason = nil)
OutboxEvent.create!(
tenant_id: tenant_id,
event_name: event_name,
record: self,
error_payload: reason
)
end
end
end
14 changes: 3 additions & 11 deletions app/models/approval_engine/step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ module ApprovalEngine
# drop a transactional-outbox event — all in one transaction — so concurrent
# "Approve" clicks can never double-resolve a step.
class Step < ApplicationRecord
include ConsensusValidatable
include Outboxable

# Lifecycle. `waiting` steps belong to a future layer and are not yet
# actionable; they are activated to `pending` once the prior layer resolves.
STATUSES = %w[waiting pending approved rejected changes_requested expired cancelled].freeze
Expand All @@ -32,7 +35,6 @@ class Step < ApplicationRecord
validates :tenant_id, presence: true
validates :status, inclusion: { in: STATUSES }
validates :layer, :iteration, numericality: { greater_than: 0 }
validate :approvals_required_is_valid

before_update :guard_immutable_transition
before_save :stamp_timing
Expand Down Expand Up @@ -274,10 +276,6 @@ def record_audit(event:, by:, comment:)
)
end

def emit_outbox(event_name)
OutboxEvent.create!(tenant_id: tenant_id, event_name: event_name, record: self)
end

def emit_activation
emit_outbox("step.activated")
end
Expand All @@ -293,12 +291,6 @@ def guard_immutable_transition
throw :abort
end

def approvals_required_is_valid
return if Consensus.valid?(approvals_required)

errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
end

# Stamp the cycle-time facts wherever a step's status changes — at build, on
# waiting->pending activation, and on a human decision — so latency reporting
# never has to re-derive timing from the audit log. `||=` keeps the first
Expand Down
11 changes: 2 additions & 9 deletions app/models/approval_engine/template_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ module ApprovalEngine
# step is expanded into one concrete Step per resolved actor, all sharing the
# layer's consensus condition (`approvals_required`).
class TemplateStep < ApplicationRecord
include ConsensusValidatable

belongs_to :track_template, class_name: "ApprovalEngine::TrackTemplate", foreign_key: "approval_engine_track_template_id"

validates :name, :assigned_group, presence: true
Expand All @@ -11,16 +13,7 @@ class TemplateStep < ApplicationRecord
# FlowDefinition already refuses one; this is the same guard for rows the
# admin writes, so both doors into this table agree.
validates :timeout_after, numericality: { greater_than: 0 }, allow_nil: true
validate :approvals_required_is_valid

scope :ordered, -> { order(:layer) }

private

def approvals_required_is_valid
return if Consensus.valid?(approvals_required)

errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
end
end
end
24 changes: 24 additions & 0 deletions app/models/concerns/approval_engine/consensus_validatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module ApprovalEngine
# Validates a row's `approvals_required` against the Consensus vocabulary.
#
# Three tables carry the spec — an Approval gathers across tracks, a Step
# across a layer's actors, and a TemplateStep is the blueprint both are built
# from — so the check lives here rather than three times over. The message is
# part of it: an admin who mistypes the spec in the rule builder and a host
# who mistypes it in the DSL should be told the same thing.
module ConsensusValidatable
extend ActiveSupport::Concern

included do
validate :approvals_required_is_valid
end

private

def approvals_required_is_valid
return if Consensus.valid?(approvals_required)

errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
end
end
end
24 changes: 24 additions & 0 deletions app/models/concerns/approval_engine/outboxable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module ApprovalEngine
# Lets a ledger row drop a transactional-outbox event about itself.
#
# The write lands in the same transaction as the state change that produced
# it — that is the whole point of the outbox — so the only thing this has to
# get right is that every row emits the same shape. `reason` carries the
# semantic why (a rejection note, a cancellation cause) into `error_payload`,
# which the relay hands to the host callback; steps pass nothing and the
# column stays null, exactly as before.
module Outboxable
extend ActiveSupport::Concern

private

def emit_outbox(event_name, reason = nil)
OutboxEvent.create!(
tenant_id: tenant_id,
event_name: event_name,
record: self,
error_payload: reason
)
end
end
end
16 changes: 15 additions & 1 deletion bin/setup
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,22 @@ cd "$(dirname "$0")/.."
echo "== Installing dependencies =="
bundle install

# Migrate rather than load schema.rb. The dump is gitignored, so on a fresh
# clone there is nothing to load; and a copy left over from an older schema (a
# renamed table, say) is worse than nothing, because Rails' maintain_test_schema
# loads it over the migrations and the whole suite then fails with errors that
# read like broken code rather than a stale database. Always build from source.
#
# The engine's migrations and the dummy app's interleave by timestamp but run
# from separate paths, so the engine needs a second pass to pick up its own
# migrations that sort after the dummy's. This mirrors CI exactly.
echo "== Preparing the test database =="
echo "Set DATABASE_URL to your Postgres if it isn't on the default socket."
bin/rails app:db:test:prepare
rm -f test/dummy/db/schema.rb
export RAILS_ENV=test
bin/rails app:db:create
bin/rails app:db:migrate
(cd test/dummy && bin/rails db:migrate)
bin/rails app:db:migrate

echo "== Done. Run the suite with: bin/rails app:test =="
11 changes: 6 additions & 5 deletions docs/launch/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Launch

> **Before posting: the origin story in `posts.md` is invented.** Every draft
> claims the author wrote this same code at three jobs. That detail was written
> to give the posts a voice, not reported from life, and it will appear under a
> real name. Replace it with what actually happened. Everything else in the
> drafts is checked against the shipped code; that one thing is not.
> **The drafts in `posts.md` make no claims about the author's career, on
> purpose.** An earlier version of each one claimed the author had built this same
> code over and over at past employers — invented to give the posts a voice, not
> reported from life, and it would have gone out under a real name. They now lead with the
> problem, which carries them fine, and are first-person only about having built
> the gem. They are ready to post as written; nothing is waiting on you.


The 1.1.0 launch runbook. This directory is working copy, not product.
Expand Down
9 changes: 9 additions & 0 deletions docs/launch/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ grep -nE 'fly\.dev|herokuapp|demo\.|\.gif|approval_engine:admin|mailer_views' do

- [ ] That grep returns nothing.

- [ ] No post claims anything about your history that isn't true. The drafts are
written to need no such claim; if you added one, make sure it is yours:

```sh
grep -niE "at (two|three|four|several|[0-9]+) (jobs|companies|employers|places)|(rewrote|rebuilt|written) (it|this|that machinery)" docs/launch/posts.md
```

- [ ] That grep returns nothing.

### 7. The release is cut

Follow [RELEASING.md](../../RELEASING.md); it is tag-driven, so the tag is the
Expand Down
Loading
Loading