Repository navigation
v1.1.0 — the adoption release - #9
Merged
Merged
Conversation
JSON Logic is the right storage format for a routing rule — portable, safe
to evaluate, expressive enough for what people actually write. It is the
wrong authoring format for a human: nobody should hand-write
{ ">" => [{ "var" => "amount" }, 10_000] } in a form field or a seed file.
Condition translates a field/operator/value triple to and from that AST, and
is honest about its limits — anything it cannot represent parses back as nil
rather than being silently mangled, so callers can fall back to raw JSON.
Values cast against the declared `exposes_for_approval` type, because a form
that submits "10000" against a numeric attribute would otherwise compare a
string to a number and quietly never match.
Shared groundwork for the admin rule builder and the flow DSL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every link shared to Slack, Reddit, HN or Twitter renders this card, so it is the most-seen asset of a launch. It led with "Multi-tenant, immutable-ledger approval workflows" — an accurate description of the machinery, aimed at someone who already knows they want it. It now leads with the job: "A manager approves, then the CFO. Unless it's over $50k — then Legal too." The chips describe outcomes rather than mechanisms, for the same reason. The card was previously an opaque PNG with no source. The HTML that generates it now lives beside it, so the next repitch is an edit rather than a redraw. assets/ is outside spec.files, so neither ships in the gem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JSON Logic overloads `in`: an Array second operand is array membership, a String second operand is a substring test. `Condition.parse` accepted both and cast the value through `cast_list`, which would read a hand-written substring rule into the simple form and save it back as membership — silently changing what the rule matches. That is precisely the rule-mangling Condition exists to prevent, so a non-Array `in` now parses as nil and falls through to the raw JSON editor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standing up one approval today takes ten concepts: a TrackTemplate, two
template_steps, a trigger_rule with hand-written JSON Logic, and only then
run_approval!. This collapses that into a single block that belongs in
db/seeds.rb:
ApprovalEngine.define_flow "High-value invoice", tenant: "acme", model: Invoice do
on :create, when: { amount: { gt: 10_000 } }
step "Manager", group: "manager"
step "CFO", group: "cfo"
end
ApprovalEngine::FlowDefinition writes ordinary TrackTemplate / TemplateStep /
TriggerRule rows, so ApprovalBuilder consumes them unchanged and the admin UI
can still edit them. It is not a parallel non-DB code path.
Re-running reconciles rather than inserts, so it is safe on every deploy. The
flow is keyed on (tenant_id, name) under a per-flow advisory lock, steps are
matched by name and updated in place so admin-UI ids survive, and a rule the
block stopped declaring is deactivated, never destroyed — approvals hold their
routing rule as provenance on a nullify FK, so a destroy would silently erase
"which rule started this?" from every approval that rule ever spawned.
An approval already in flight is untouched: ApprovalBuilder copies a template
step's facts onto the ledger at build time and IterationBuilder clones a rework
iteration from those ledger rows, not the template. Tests assert a running
approval keeps its own steps, completes through them, and replays them on
request_changes! after the flow is redefined underneath it.
Everything the DSL can catch it catches at definition time, with a message that
names the fix — the opposite of runtime rule evaluation, which deliberately
fails closed and silent. That includes the gem's most expensive silent failure:
a `when:` naming an attribute the model does not expose would read as a clean
non-match and simply never fire, so it now stops the seed instead.
Also adds `require "approval_engine/condition"` (a Rails engine autoloads app/,
never lib/, so the constant was unreachable) and the opt-in
`config.approval_groups` allowlist, which defaults to nil and checks nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ctivated event
An approval system whose approver is never told they have an approval waiting is
a half-system. The engine fired model callbacks and stopped: every adopter had to
write the notification layer themselves, and TimeoutSweepJob could detect a
stalled step without any way to tell a human.
Ship six notifications through the existing transactional outbox:
step_activated / step_reminder / step_reassigned -> the assignee
changes_requested / approval_approved / approval_rejected -> approval_recipients
Mail is dispatched from ProcessOutboxJob (so a message is only ever built for
state that has already committed, and a down SMTP server can never roll back an
approval) and sent with deliver_later (so a mail outage retries the *mail*, never
the outbox event — a redelivery would re-run the host's callbacks). Anything the
notifier raises is logged and swallowed for the same reason.
Fill the one gap the outbox did not cover: nothing was emitted when a step became
actionable. Step now emits a first-class `step.activated` event on build and on
waiting->pending activation, with a matching `after_step_activated(step)` host
callback and an `approval_engine.step.activated` notification channel.
Add ReminderSweepJob / Step.sweep_reminders! / Step#remind!, modelled on the
timeout sweep and made idempotent by a new nullable `reminded_at` column: a sweep
that runs every ten minutes nudges each approver at most once. The column is
referenced only from reminder code paths, so an install that upgrades the gem
without running the migration loses reminders, never approvals.
Backward compatibility:
* config.notifications_enabled defaults to false. Four further independent
gates (notification_events, a resolvable address, approval_recipients, and
reminder_after) stand behind it. A regression test asserts a full
approve-and-drain cycle under default config sends nothing.
* `step.activated` is a NEW outbox event and roughly doubles outbox row volume
for every install, notifications or not. Its relay is cheap (a respond_to?
miss, a no-op instrument, and an early return), but hosts pruning processed
rows should expect the table to grow about twice as fast.
* Upgrading requires `rails approval_engine:install:migrations && rails db:migrate`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README's headline claim is "routing rules that admins change at runtime, without a deploy", but the engine shipped no CRUD for TrackTemplate, TemplateStep or TriggerRule anywhere. This backs the claim with code. Gated by a new `config.admin_enabled`, which DEFAULTS TO FALSE. 1.0 hosts mounted a read-only dashboard behind whatever auth they judged adequate for information disclosure; turning write endpoints on during a routine `bundle update` would turn that same route into "anyone who can reach this can approve a payment". The flag is read twice: at route-draw time, so the write routes do not exist at all for a host that never opts in, and again per request in Admin::BaseController, so a boot-time decision that later flips false cannot be relied on. The rule editor is a zero-JavaScript field/operator/value builder driven by ApprovalEngine::Condition, with a CSS-only radio toggle to a raw JSON Logic textarea. It degrades honestly: when Condition.parse returns nil the stored AST is more than the simple form can represent, so the raw editor opens and the simple inputs are disabled rather than silently rewriting a rule someone hand-wrote. The field dropdown is populated from `exposes_for_approval` (via a new name-based approvable registry) and flags a field nobody exposes, which is the most common way a hand-typed rule silently never fires. Two data-integrity guards: deleting a template whose rules have already routed approvals is refused in favour of archiving, because the FK nullifies provenance on the ledger; and a raw condition whose root is not a JSON object is rejected, because JSON Logic evaluates a non-object as a truthy literal and such a rule would route every event of its name. The read-only dashboard is unchanged for a host that never opts in: the nav and flash regions are wrapped in `admin_ui?` and the CSS additions are purely additive. Also forces the dummy app to draw its routes at the end of boot. With eager_load = false Rails defers the draw to the first test that touches a route helper, by which time ApprovalEngine::TestCase may have reset the configuration -- which made the admin routes come and go with the Minitest seed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The demo seeded one freshly created approval, which renders an empty audit trail — the least interesting thing this engine does. The point of an append-only ledger is what it remembers, so the seed now includes finished work: an approval that ran to completion, one rejected at the last step with the reason recorded, one sent back for rework, and a scatter-gather holding at Legal and IT simultaneously. Six approvals across three states also gives the dashboard's status filters something to filter, and gives `bin/demo` a first screen worth looking at. Screenshots captured from that seed, for the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README opened with "Multi-tenant, immutable-ledger human approval flows for Rails" — an accurate spec sheet aimed at someone who already knows they want it. It now opens with the job, and with the story of how an `approved` boolean turns into an audit question nobody can answer. The "Look elsewhere when" list used to route its largest audience away with "you just need a boolean flag." That was honest but self-defeating: the people who need one approver today are the people who need five next year. It now shows the column becoming a projection of the ledger, so adopting the gem does not mean rewriting the queries they already have. Adds test/docs_test.rb, which fails CI when the README documents a config key, an ApprovalEngine method, or a generator that does not exist, and when an image is referenced by a relative path (assets/ is not packaged, so a relative path renders only on github.com). A README whose first code block raises NoMethodError is a worse launch than no launch. Also: 1.1.0 CHANGELOG with an explicit "nothing turns itself on" upgrade note, gemspec metadata pinned so a later homepage change cannot silently repoint rubygems' source link, the cookbook and architecture docs brought up to date, and docs/launch/ — the posts and the pre-flight checklist for the announcement 1.0 never got. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 60-second quickstart sets `config.current_tenant_method = -> { Current.account }`
at the top and `Current.account = account` forty lines later, but only the
Configuration section further down explains that `Current` is the reader's own
ActiveSupport::CurrentAttributes class, and that a single-tenant app can return
a constant instead.
Anyone reading top to bottom — which is everyone, for the block advertised as
taking 60 seconds — hits `NameError: uninitialized constant Current` first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every draft post carries a version of "I wrote this same code at three jobs and got tired of it". That detail was written to give the posts a voice; it was not reported from life, and it would appear under a real name on Hacker News and Reddit — where an origin story is exactly what a commenter asks a follow-up question about. Marked at the top of both files, with the line numbers, so it cannot be pasted by accident. Every other claim in the drafts is checked against the shipped code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a trigger rule from the admin erased provenance. The FK from Approval is on_delete: :nullify, so one click blanked "which rule started this?" on every approval that rule ever routed. The template's own delete path already refuses this and reconcile_rules! already deactivates instead of destroying — the rule controller was the one place that didn't. It now refuses too, and points at unticking Active. Subclassing NotificationMailer — the documented config.mailer_class route — raised ActionView::MissingTemplate for all six notifications, because Action Mailer resolves templates from the subclass's own mailer_name. Pinning `default template_path:` on the engine's mailer means a subclass inherits the engine's views and overrides only what it rewrites. define_flow rejected valid JSON Logic. `var` is also written `["amount", 0]` to supply a default and `"a.b"` to walk nested data; both were compared verbatim against the exposure list, so the seed died blaming an attribute name nobody had typed. parallel(approvals_required: <count>) wrote a template ApprovalBuilder then refused to build: the count reads as flat across the layer, but the builder validates it against each group's own members, so two one-person groups needing 2 raised BuilderError on every run. Refused at definition time now, where the error can explain itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CHANGELOG promised "no behaviour change" on upgrade. That was not quite true: the new `step.activated` outbox event is emitted unconditionally, so an existing install gets roughly one extra outbox row per step, wildcard `approval_engine.*` subscribers start seeing a channel they did not have, and a model that happens to define `after_step_activated` starts having it called. The gate stays as it is — adding a channel is additive and the two surfaces that touch users or host code are both properly off — but the upgrade note now says so instead of overclaiming. Also corrected: the README said mail "retries with backoff", which the outbox gives the relay and not the mail job (the engine sets no retry policy, so a bounce is whatever Active Job already does); it said `approval.step` reads back any single-track approval, when it is `sole` and raises on the very two-step flow the README teaches; and a screenshot's alt text described a time-in-step column that is on the detail page, not the listing. `approval_url_builder` was shown calling a bare `invoice_url` in an initializer, where url helpers are not in scope — it now goes through the routes proxy, in both the architecture doc and the post-install notes. The reminder migration suggested building its index with CREATE INDEX CONCURRENTLY by hand, but `add_index` would then have failed on the existing index; `if_not_exists` makes that path actually work. And the reminder docs now warn that the first sweep nudges every step already past the threshold, with the query to check what that is before scheduling it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TemplateStep validated `layer` but not `timeout_after`, so a zero or negative deadline typed into the admin produced steps that were overdue the instant they were created. FlowDefinition already refused one; both doors into the table now agree. `reassign!` left `reminded_at` alone, so a step handed over after it had already been nudged would never be nudged again — the person given a stalled approval was the one person the sweep stayed quiet about. Handing it over restarts the clock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new code carried 743 lines of comment across the release — 18% of everything added. That is too much: multi-paragraph rationale in front of methods whose names already say what they do, and prose in tests that the test name states more precisely. Trimmed to 488 lines (11%). What survives is the reasoning that would otherwise be re-litigated or undone by accident: why rules deactivate rather than delete, why both new surfaces default to off, why an `in` with a String operand is treated as unrepresentable, why the mailer pins its template path. Section banners in tests are gone, code examples in doc comments are kept, and no pre-existing comment was touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Host records are referenced polymorphically, and those columns were created by `t.references`, which defaults to bigint. A host whose models use UUID primary keys — the multi-tenant B2B shape this gem is aimed at — got the id cast to 0. The row saved, nothing raised, and `approval.target` read back nil: an audit ledger pointing at nothing, and no way to notice from a smoke test that doesn't check the association. They are string columns now. That fits a bigint, a UUID and a ULID with no configuration, which matters more than the index width at this volume — and it removes the failure mode rather than asking an adopter to avoid it. Also brings CI up to the range the gemspec claims. It asserted Rails >= 7.0.8, < 9.0 while testing exactly one Rails, so four supported versions had no evidence behind them. There is a Rails axis now, which turned up only harness problems, all fixed here: the dummy's migrations were stamped with a Rails version older releases can't read, schema.rb can't be loaded across the matrix (so it migrates instead), Rails 7.x needs Minitest 5, and two tests called Rails 8-only APIs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new CI matrix immediately found two things the single-version build could not. Rails 7.0 was never going to work: the outbox relay retries with `wait: :polynomially_longer`, which 7.1 introduced — on 7.0 every retry raises "Couldn't determine a delay". Rather than carry a shim for a release that is past security support, the gemspec now claims 7.1, which is what CI proves. Rails 8.0 failed on the committed `test/dummy/db/schema.rb`, which is stamped with whichever Rails dumped it and cannot be read by an older one. Now that every environment migrates from scratch it has no job left, so it is deleted and ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ApprovalEngine::TestHelpers` is the gem's answer to "how do I test my approval flow?" — until now every adopter hand-rolled fixtures. Actions and queries rather than assertions, so a host reads the same under Minitest and RSpec. `drain_approval_outbox!` is the one that matters most: side-effects reach the host through the outbox via ActiveJob, so until the queue runs `after_approved` has genuinely not fired, and a test asserting on it fails for the right reason while looking like the wrong one. Three defects found in the release review, all in the same area: Step notifications ignored delegations. `Step.actionable_by` includes an active delegate, so a delegate could act on a step while the mail went only to the person who had delegated it away — telling the one person who was not going to do anything about it. The changes_requested email named the assignee rather than whoever actually asked. The ledger records the acting actor; the mail now reads it. The condition size cap guarded only the raw JSON editor. The simple builder could assemble an unbounded condition, evaluated on the hot path of every matching save. It is enforced where the condition is assembled, so both doors share it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
approval_engine1.0.0 shipped on 2026-06-17 and was never announced. The numbers as of today: 5 unique GitHub visitors in 14 days, 1 star, 0 forks, one all-time referrer (rubygems.org), and zero mentions of the gem anywhere on the web. It was not evaluated and rejected — it was never seen.Two real product gaps turned up while diagnosing that:
config/routes.rbwasresources :approvals, only: %i[index show]and there was no CRUD forTrackTemplate,TemplateSteporTriggerRuleanywhere in the gem. Admins had to use a Rails console.Prior art says the category has demand:
ringlesoft/laravel-process-approvalhas 552 stars for the same idea in Laravel.What's in it
ApprovalEngine.define_flow— a declarative, idempotent flow builder. Getting one approval running used to take ~10 concepts. Now:It writes real
TrackTemplate/TemplateStep/TriggerRulerows, so the admin UI can still edit them. It reconciles rather than inserts, so it is safe indb/seeds.rbon every deploy — and it cannot disturb an in-flight approval, becauseApprovalBuildercopies template facts onto the ledger rather than holding a foreign key. Rules are deactivated, never destroyed, becauseApproval#trigger_ruleison_delete: :nullifyand deleting one would silently erase provenance.A built-in admin UI, gated by
config.admin_enabled, default false. Full CRUD for templates, steps and rules, with a zero-JavaScript rule builder: field dropdown populated fromexposes_for_approval, human operator labels, and an automatic fallback to a raw JSON Logic textarea for any condition the simple form cannot represent without changing its meaning.Default false is deliberate and not a preference: 1.0 shipped a read-only dashboard that hosts mounted behind whatever auth they judged adequate for information disclosure. A gem upgrade must not turn that route into "anyone who can reach this can approve a payment."
Notifications and reminders, gated by
config.notifications_enabled, default false. Six mailer actions, HTML and text, dispatched through the existing transactional outbox so a dead SMTP host can never roll back an approval.ReminderSweepJobnudges quiet approvers, idempotently.ApprovalEngine::Condition— the shared JSON Logic authoring seam. JSON Logic is the right storage format and the wrong authoring format; this translates between a field/operator/value triple and the AST, and is honest about its limits rather than mangling a rule someone hand-wrote.Positioning. The README opened with "Multi-tenant, immutable-ledger human approval flows for Rails" — an accurate spec sheet aimed at someone who already knows they want it. It now opens with the job. The "Look elsewhere when" list used to route its largest audience away with "you just need a boolean flag"; it now shows that column becoming a projection of the ledger, so adopting doesn't mean rewriting the queries you have.
Also:
test/docs_test.rbfails CI when the README documents a config key, anApprovalEnginemethod, a generator, or an image that doesn't exist. A README whose first code block raisesNoMethodErroris a worse launch than no launch.Review
An adversarial pass over the diff (five lenses; each finding independently attacked by a skeptic and a line-by-line tracer) found defects that are fixed here:
reconcile_rules!already deactivated instead of destroying — the rule controller was the one place that didn't.NotificationMailerraisedActionView::MissingTemplatefor all six notifications, because Action Mailer resolves templates from the subclass's ownmailer_name. The documentedconfig.mailer_classroute did not work.define_flowrejected valid JSON Logic.varis also written["amount", 0]and"a.b"; both were compared verbatim against the exposure list, so the seed died blaming an attribute name nobody typed.parallel(approvals_required: <count>)wrote a templateApprovalBuilderthen refused to build — the count reads as flat across the layer but is validated per group.TemplateStepvalidatedlayerbut nottimeout_after, so a negative deadline typed into the admin produced steps overdue on creation.reassign!never clearedreminded_at, so the person handed a stalled approval was the one person the sweep never reminded.inround-tripped throughConditionas array membership, silently changing what a hand-written rule matched.The CHANGELOG's upgrade note was also overclaiming. It said "no behaviour change"; in fact
step.activatedis emitted unconditionally, so existing installs get one extra outbox row per step and wildcardapproval_engine.*subscribers see a new channel. The gate stays as-is — that's additive, and both surfaces that touch users or host code are properly off — but the note now says so.Findings deliberately not fixed are recorded in #8.
Verification
bundle exec rubocopclean across 78 files.gem buildclean; packaging asserted bytest/gemspec_test.rb(docs ship,assets/anddocs/launch/do not).Not done, on purpose
bin/demois the path, and the seed now produces a lived-in dataset rather than one empty approval.jsonbandginare load-bearing for routing; changing that is an architecture project, not an adoption release.docs/launch/posts.md— and their origin story is invented and flagged as such; it needs replacing with what actually happened before anything goes out.🤖 Generated with Claude Code