When the UI Promised More Than the Backend Could Deliver
The bug manifested innocuously: a button labeled "Submit for Review" that, when clicked, appeared to work — no error message, no stack trace, no indication that anything was wrong. But the record wasn't entering a review stage. It was being sent to an entirely different workflow step. Users assumed their action had succeeded, while data quietly accumulated in the wrong state.
The root cause was a UI-backend contract violation. A designer had updated the workflow visualisation to include an intermediate review step, reflecting how the business process actually worked. The UI was updated, the button was relabeled, and the view was copied from the existing approve button. What didn't happen was updating the route helper to point to a new action — or creating the backend infrastructure for that action: no route, no controller action, no model method, and no authorisation policy.
# What the view promised
= button_to "Submit for Review", pending_review_workflow_path(@record)
# What actually existed in routes.rb
resources :workflows do
member do
post :approve # ✓ exists
post :reject # ✓ exists
# pending_review? missing entirely
end
end
In this case, the view had been copied from the existing approve button, and the route helper was never updated to point to the new action. Requests hit the approve action instead of a not-yet-created transition_to_review action. Records moved to the wrong workflow stage — no error was raised because every layer the request passed through was technically valid, just wrong.
What made diagnosis difficult was the cascade effect. The incorrect route was only one of several problems. Once we started investigating, we found five distinct but interrelated bugs: silent parameter drops, duplicate audit records, incorrect user attribution, and a missing policy method. Understanding these failures requires understanding Rails' four-layer contract for feature completeness: route, policy, controller, and model.
Anatomy of the Bug: Five Failures, One Root Cause
What looked like five independent bugs turned out to be symptoms of a single architectural failure: the UI was updated to reflect a new workflow step, but the complete backend stack was never implemented.
Here's how the failures cascaded:
1. The Wrong Action
The view was copied from the existing approve button. The route helper was never changed, so the "Submit for Review" button posted to transition_to_approved_path — a valid route that triggered the wrong workflow transition entirely. No error was raised.
2. The Authorization Gap
When we added the correct route, requests hit a NoMethodError because the policy class had no transition_to_review? method. Pundit doesn't silently deny when a method is missing — it raises an exception. This was actually the easiest failure to find because it was loud.
3. The Non-Existent Model Method
The controller action called @record.transition_to_review!, which didn't exist. Like the policy failure, this raised a clear NoMethodError.
4. The Silent Data Loss
The transition form used notes as the field name, but the controller read params[:transition_notes]:
What the form sent
{ "notes" => "Ready for final approval" }
What the controller read
params[:transition_notes] # => nil
ActiveRecord happily saved the record with blank notes. No error, no warning—just missing data.
5. The Duplicate Records
Once the action existed, each transition created two audit records: one from the controller's manual audit_records.build, another from a before_update callback that fired on status changes. Both paths assumed they were the only writer.
The Pattern
In authorization-driven Rails apps, a new user action requires four aligned layers:
- Route (
resources :records do post :transition_to_review end) - Policy method (
def transition_to_review?) - Controller action (
def transition_to_review) - Model method (
def transition_to_review!)
Skip any layer, and you get failures ranging from loud exceptions (missing routes, missing methods) to genuinely silent data corruption (wrong param keys, duplicated callbacks). The most dangerous bugs are in the silent category — they don't trigger error trackers because no exception is raised. The UI worked perfectly; it just sent data to the wrong place.
The Four-Layer Alignment Requirement in Rails
In a well-architected Rails application, every user-facing feature requires coordination across four distinct layers, each serving a specific purpose in the request-response cycle. Understanding this dependency chain is critical for preventing the kind of UI-backend misalignment that caused our workflow bug.
The four layers and their responsibilities:
Complete four-layer alignment for a workflow transition:
1. Route
post 'records/:id/submit_for_review', to: 'records#submit_for_review'
2. Policy
class RecordPolicy
def submit_for_review?
user.reviewer? && record.draft?
end
end
3. Controller
def submit_for_review authorize @record @record.submit_for_review! redirect_to @record end
4. Model
def submit_for_review! update!(current_stage: 'under_review', status: 'pending') end
When the workflow UI was updated to include a new intermediate step, only the view layer changed — and it was copied from an existing button. The route helper still pointed to the approve action, so the form silently sent records to the wrong workflow stage. Meanwhile, the three lower layers for the new transition didn't exist at all: no policy method, no controller action, and no model method. This created a situation where the initial failure was invisible (wrong action, no error) and the subsequent failures during the fix were a cascade of missing implementations.
Request Flow: Wrong Route from Copied View
- Routes (
config/routes.rb) — Define which HTTP verbs and paths are valid and map them to controller actions - Policies (Pundit/authorization layer) — Determine whether the current user is allowed to perform the action
- Controllers — Orchestrate the business logic, handle parameters, and coordinate the response
- Models — Encapsulate business rules and perform the actual state transitions
Each layer serves a distinct purpose. A route without a policy creates a security hole — the action is accessible but unguarded. A route without a controller action raises AbstractController::ActionNotFound. A controller action without a corresponding model method raises NoMethodError. But a form pointing to the wrong existing route — where every layer is present but for a different action — produces no error at all.
Root Cause Analysis: How UI-First Development Goes Wrong
The bug cascade began with a deceptively simple change: updating button labels and workflow descriptions to reflect a new intermediate stage. The UI team made the frontend changes, shipped them to production, and moved on to the next sprint. The problem? Nobody added the corresponding backend infrastructure.
This is UI-first development in its most dangerous form. The pattern emerges from understandable organisational pressures: frontend teams moving faster than backend capacity, designers iterating on user flows without technical review, or "quick fixes" that touch only what's visible to users. In workflow-heavy applications, this creates a particularly insidious failure mode — the UI promises functionality that simply doesn't exist.
When users clicked the new "Submit for Review" button, they expected their records to enter a review stage. But the view had been copied from the existing approve button, and the route helper was never updated. The form action pointed to a valid but wrong route — the approve action. Records moved to the wrong workflow stage with every click. The application appeared to work — no error pages, no failed requests — but data was being routed to the wrong action entirely.
What the UI implied:
<%= form_with url: submit_for_review_path(@record) do |f| %>
What the view actually used (copied from the approve button):
post '/records/:id/approve' # Valid route, wrong action
In Rails applications with authorisation layers, a complete feature requires alignment across four levels: routes, policies, controller actions, and model methods. Some misalignments fail loudly — a missing controller action raises an exception, a missing policy method raises NoMethodError. But others fail silently: a form pointing to the wrong existing route executes the wrong action with no error, and a param name mismatch drops data without complaint.
The real danger isn't the initial breakage — it's the silent degradation. Users learn to work around the broken feature. Data accumulates in incorrect states. By the time someone investigates, the trail of inconsistency runs through thousands of records.
Four-Layer Rails Alignment Architecture
Debugging Multi-Layer Failures: A Systematic Approach
When a multi-layer framework like Rails hides complexity, bugs often manifest far from their source. The workflow bug appeared as a button sending records to the wrong stage, but the root cause was four missing layers of implementation. Here's a systematic approach for diagnosing these failures:
Start with the data layer. When behavior seems wrong, verify what's actually persisted. Use rails console to inspect the database state directly:
What actually got saved?
Record.last.inspect Record.last.audit_records.order(:created_at).pluck(:from_stage, :to_stage, :notes)
This immediately revealed duplicate transition records—a smoking gun pointing to overlapping logic paths.
Trace the request path backward. Follow the data from view to database:
post transition_record_path(@record), params: { notes: "Test note" } assert_equal "Test note", @record.audit_records.last.notes
- View: What parameters does the form submit? Check
name=attributes. - Route: Does
rails routes | grep transitionshow the expected path? - Controller: Add logging before and after the action:
Rails.logger.debug "Params: #{params.inspect}" - Model: Does the method exist? Is a callback firing? Add
Rails.logger.debugto callbacks.
The silent notes parameter loss was caught by comparing form field names against controller parameter keys. No error occurred because params[:transition_notes] simply returned nil.
Check all four layers for new actions. In authorization-heavy Rails apps, every user-facing action requires alignment across:
- Route definition (
resources :records do ... end) - Policy method (
def transition_to_review?) - Controller action (
def transition_to_review) - Model method (
def transition_to_review!)
Missing layers produce a mix of loud and silent failures. Missing routes and methods raise exceptions — those are easy to find. But a form wired to the wrong action, or a param key that doesn't match, produces no error at all. The original bug stayed hidden precisely because the request succeeded — it just did the wrong thing.
Use integration tests to catch silent failures. Unit tests passed because each layer worked in isolation. Only end-to-end tests would have caught the parameter name mismatch or the wrong route:
The Complete Implementation Checklist
When implementing a new workflow step or any feature that spans multiple layers of your Rails application, use this checklist to ensure complete, aligned implementation:
Routes & Policies
- [ ] Add route entry in
config/routes.rbfor each new action - [ ] Add corresponding policy method in
app/policies/*_policy.rb - [ ] Verify policy test coverage in
spec/policies/*_policy_spec.rb - [ ] Confirm authorization check in controller (
authorize @resource)
Controller Layer
- [ ] Implement action in appropriate controller
- [ ] Match parameter names between form fields and strong parameters
- [ ] Add explicit integration test verifying param names match:
expect(Record.last.notes).to eq("test notes") - [ ] Set callback suppression flags if model callbacks duplicate controller logic
- [ ] Verify acting user attribution (
@record.acting_user = current_user)
Model & Business Logic
- [ ] Add transition method (e.g.,
approve!,submit_for_review!) - [ ] Ensure method signature matches calling pattern (param-less bang methods for form actions)
- [ ] Review
before_update/after_updatecallbacks for potential duplication - [ ] Add
attr_accessorflags for callback control (skip_audit_callback) - [ ] Confirm database columns exist for all assigned attributes (
rails dbconsole,\d table_name)
View Layer
- [ ] Verify form
action:path matches new route (not a similar existing route) - [ ] Confirm button labels reflect actual destination, not aspirational workflow
- [ ] Match form field names to controller parameters exactly
- [ ] Add request spec that submits the form and verifies the full round trip
Integration Verification
- [ ] Click through the UI workflow in staging/development
- [ ] Inspect database records to confirm all fields persisted
- [ ] Check audit trail attribution and metadata completeness
- [ ] Verify no duplicate records created from overlapping callbacks
The most insidious bugs come from param name mismatches — params[:wrong_key] returns nil with no error raised. Note that mass-assigning to non-existent columns does raise ActiveModel::UnknownAttributeError, so Rails catches that class of mistake. The truly silent failures are wrong param keys and duplicated callback logic. Always verify persistence with database-level assertions.
Prevention Strategies: Process and Tooling
Preventing UI-backend misalignment requires both process discipline and strategic automation. The workflow bug revealed gaps at four distinct layers—routes, policies, controllers, and models—suggesting that traditional code review alone isn't sufficient when frontend and backend changes span multiple pull requests.
Code Review Checklists
For feature work touching user-facing workflows, reviewers should verify all four layers exist before approving:
✅ Complete feature implementation
config/routes.rb
post 'records/:id/submit_for_review', to: 'records#submit_for_review'
app/policies/record_policy.rb
def submit_for_review? user.can_submit?(record) end
app/controllers/records_controller.rb
def submit_for_review authorize @record @record.submit_for_review!(current_user) end
app/models/record.rb
def submit_for_review!(user) update!(current_stage: 'under_review', acting_user: user) end
Integration Testing Strategy
Silent failures from parameter mismatches demand integration tests that verify the complete request-response-persistence cycle:
spec/requests/workflow_transitions_spec.rb
it "persists transition notes" do
post submit_for_review_record_path(@record),
params: { notes: "Ready for approval" }
expect(@record.reload.audit_records.last.notes)
.to eq("Ready for approval")
endTeam Workflow Adjustments
Consider requiring that UI mockups or Figma updates trigger a technical design discussion before implementation begins. When Product updates a workflow diagram, Engineering should generate a quick architectural checklist: "This adds one button, which means we need one route, one policy method, one controller action, and one model state transition." Treating UI changes as API contracts prevents the drift that creates these cascading failures.
Feature flags can also help by allowing backend scaffolding to deploy before UI exposure, ensuring all layers exist before users encounter new workflows.
Lessons Learned and Broader Implications
This incident highlights a fundamental tension in Rails development: the framework's convention-over-configuration philosophy encourages rapid UI iteration, but multi-layer features require coordinated changes across routes, policies, controllers, and models. When these layers drift apart, bugs range from loud exceptions to silent data corruption.
The most insidious aspect was how some layers failed silently while others failed loudly. Missing routes and missing methods raised clear exceptions that were easy to find. But params[:wrong_key] returning nil, a copied route helper pointing to the wrong action, and duplicated callback logic — these produced no exceptions at all. The application appeared functional while quietly corrupting data.
This experience crystallised a mental model we now apply to every workflow feature: the four-layer checklist. Before marking any state transition as complete, we verify:
1. Route exists
resources :records do member { patch :advance_to_review } end
2. Policy permits action
def advance_to_review? user.reviewer? && record.submittable? end
3. Controller action exists and handles params correctly
def advance_to_review @record.acting_user = current_user @record.advance_to_review!(notes: params[:transition_notes]) end
4. Model method implements business logic
def advance_to_review! update!(current_stage: 'review', status: 'pending') end
We've also become more skeptical of "quick UI fixes." When a PM requests changing a button label, we now treat it as a trigger to audit the entire feature stack. Often, the mismatch between UI labels and backend behavior reveals drift that's been accumulating for months.
The broader lesson is that stateful applications require constant vigilance. Callbacks, in particular, create implicit dependencies that break when assumptions change. The skip_audit_callback pattern works but signals that your callback's preconditions may be too broad. In retrospect, extracting audit logic to a service object would have made the dual-path pattern explicit rather than hidden in accessor flags.