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 but silently failed. The workflow UI displayed a clean progression through stages—draft, pending review, approved—but clicking the "pending review" transition did nothing. No error message. No stack trace. Just a record that stayed in its original stage while users assumed their action had succeeded.
The root cause was a classic UI-backend contract violation. A designer had updated the workflow visualization to include an intermediate review step, reflecting how the business process actually worked. The UI was updated, the button was relabeled, and the feature shipped. What didn't ship was the supporting infrastructure: no route for the new transition, no controller action to handle it, no model method to execute it, and no authorization policy to govern it.
# 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
This type of failure is particularly insidious because Rails' routing layer happily accepts the request—it just routes it to a NoMethodError that gets swallowed somewhere in the stack, or worse, routes to a similar-sounding action that does something, just not the right thing.
What made diagnosis difficult was the cascade effect. The missing backend support created five distinct but interrelated bugs, each masking the others: silent parameter drops, duplicate audit records, incorrect user attribution, and phantom database columns. 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 Missing Action
A button labeled "Submit for Review" posted to transition_to_approved_path because no transition_to_review_path route existed. The UI reflected reality; the backend didn't.
2. The Authorization Gap
Even with the route added, requests failed with 403 errors. The policy class had no transition_to_review? method, causing Pundit to deny by default.
3. The Non-Existent Model Method
The controller action called @record.transition_to_review!, which didn't exist. Unlike the silent failures above, this at least raised a 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 silent failures that don't appear in logs or error trackers. The UI worked perfectly—it just sent data into the void.
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. The button label and user expectations evolved, but the three lower layers remained unchanged. The form pointed to a non-existent route, which cascaded into missing policy checks, absent controller logic, and no model method to safely perform the transition. This created a misalignment that manifested as silent data corruption rather than obvious errors.
Request Flow: Missing Implementation Cascade
- 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 depends on the one before it. A route without a policy creates a security hole. A policy without a controller action results in a routing error. A controller action without a corresponding model method either fails or performs operations the domain layer doesn't support.
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 organizational 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. Instead, the form action pointed to a route that didn't exist for this stage. Rails silently fell back to the closest matching route, inadvertently sending records to an entirely different workflow step. The application appeared to work—no error pages, no failed requests—but data was being routed incorrectly.
What the UI implied:
<%= form_with url: submit_for_review_path(@record) do |f| %>
What actually existed in routes.rb:
... nothing for submit_for_review ...
What Rails matched instead:
post '/records/:id/submit' # Different stage entirely
In Rails applications with authorization layers, a complete feature requires alignment across four levels: routes, policies, controller actions, and model methods. Skip any layer and you get partial functionality that fails in subtle ways. The UI-first approach inverts this dependency chain, building from the top down instead of from the data model up.
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 any layer creates silent failures. The original bug existed because only the view was updated.
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 missing 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 and silent attribute assignments to non-existent columns—both fail silently in Rails. 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 manifest as silent failures rather than loud exceptions.
The most insidious aspect was how each layer failed gracefully. Rails's forgiving nature—params[:missing_key] returns nil, unknown attributes are silently ignored, missing routes render 404s without stack traces—meant no single error pointed to the root cause. The application appeared functional while quietly corrupting data.
This experience crystallized 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.