<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DevGab</title>
    <description>The latest articles on DEV Community by DevGab (@devgab).</description>
    <link>https://dev.to/devgab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1756339%2F6d0b6825-f55c-439e-9950-4f978e3f3c2b.png</url>
      <title>DEV Community: DevGab</title>
      <link>https://dev.to/devgab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devgab"/>
    <language>en</language>
    <item>
      <title>How AI Agents Verify Cross-Service Integrations: The 4-Step Verification Pattern</title>
      <dc:creator>DevGab</dc:creator>
      <pubDate>Fri, 20 Mar 2026 01:07:39 +0000</pubDate>
      <link>https://dev.to/devgab/how-ai-agents-verify-cross-service-integrations-the-4-step-verification-pattern-44m4</link>
      <guid>https://dev.to/devgab/how-ai-agents-verify-cross-service-integrations-the-4-step-verification-pattern-44m4</guid>
      <description>&lt;h2&gt;
  
  
  The Bug Code Review Often Misses
&lt;/h2&gt;

&lt;p&gt;Imagine implementing a feature perfectly — clean code, passing tests, approved PR. Now imagine that feature is completely unreachable because the service that was supposed to call it never did. No error. No failing test. Just silent, dead code sitting in production.&lt;/p&gt;

&lt;p&gt;This is the class of bug that lives at &lt;em&gt;integration boundaries&lt;/em&gt; — between repositories, services, teams, async producers and consumers, or config layers. It's largely invisible to traditional code review because reviewers usually operate closer to the PR boundary than the architectural boundary. The failure often isn't obvious in any one file — it's in the missing link between pieces of code that each look correct in isolation.&lt;/p&gt;

&lt;p&gt;What I want to dig into here is a structured verification pattern I've found effective for AI-assisted audits of these gaps. You can apply the same logic manually or build tooling around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Grep Isn't Enough (But You Still Need It)
&lt;/h2&gt;

&lt;p&gt;When auditing integration boundaries, one naive approach is to grep for method names across repos. Found it? Great, it exists. Didn't find it? Flag it as missing. This misses half the problem.&lt;/p&gt;

&lt;p&gt;Existence isn't the same as reachability. A method can exist but be &lt;code&gt;private&lt;/code&gt;. It can exist but only be called in a test context. It can exist but be conditionally gated behind a feature flag that's disabled everywhere. Verification has to go deeper than a filename match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-Step Verification Pattern
&lt;/h2&gt;

&lt;p&gt;When auditing an integration boundary, a structured checklist I've found works well — whether you're prompting an AI agent or doing it manually — follows this chain:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Confirm the Contract Exists
&lt;/h3&gt;

&lt;p&gt;Start with the simplest question: does the method, endpoint, or hook actually exist in the codebase? What you're searching for depends on the boundary type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In-process:&lt;/strong&gt; method definition exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/API:&lt;/strong&gt; route and controller action exist&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async:&lt;/strong&gt; event is emitted, consumer/worker is subscribed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config:&lt;/strong&gt; flag or env var is defined and wired into deployment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; writers and readers agree on keys and shape
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Does the method exist anywhere in this repo?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"def render_for_all_platforms"&lt;/span&gt; ./services/

&lt;span class="c"&gt;# Does a route/action exist for it?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"render_for_all_platforms&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;render_all"&lt;/span&gt; ./config/routes.rb ./app/controllers/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is table stakes. If the target doesn't exist, nothing else matters. But passing this step means almost nothing on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Check the Access Modifier
&lt;/h3&gt;

&lt;p&gt;This is the step most manual reviews skip. In ordinary Ruby code, a &lt;code&gt;private&lt;/code&gt; method can't be called with an explicit receiver — so it's a strong signal that it's not intended as a cross-boundary entry point. (Ruby being Ruby, &lt;code&gt;send&lt;/code&gt; and metaprogramming can bypass this, but if you're relying on that for a planned integration, something has gone wrong.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Red flag: method is private but expected to be called from a job&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VideoRenderService&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;trigger_render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="c1"&gt;# public, callable — but only handles single-platform renders&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_for_all_platforms&lt;/span&gt;
    &lt;span class="c1"&gt;# intended to be called by RenderJob, but private makes it unreachable&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent or reviewer auditing this would flag it: the plan says &lt;code&gt;render_for_all_platforms&lt;/code&gt; should be invoked from the job scheduler, but the access modifier prevents normal invocation. A reviewer looking only at the job scheduler PR would likely miss this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Find the Caller
&lt;/h3&gt;

&lt;p&gt;Even if the method is public, you need to verify something actually calls it. Search across &lt;em&gt;all&lt;/em&gt; affected repositories, not just the one where the method lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Search across multiple repos (run from a parent directory)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"render_for_all_platforms"&lt;/span&gt; ./cms-service/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"render_for_all_platforms"&lt;/span&gt; ./analytics-service/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"render_for_all_platforms"&lt;/span&gt; ./renderer/

&lt;span class="c"&gt;# If using a monorepo or shared tooling:&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"render_for_all_platforms"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.ts"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero results across all repos when you expect a caller? That's a strong signal the feature may be unreachable. It's implemented, it works in isolation, but nothing triggers it. (Caveats: dynamic dispatch, reflection, generated clients, or external callers outside the repos you searched can produce false negatives — but zero grep hits should always trigger investigation.) This is exactly the kind of issue that slips through when PRs are reviewed in sequence by different engineers across different weeks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Trace the Wiring
&lt;/h3&gt;

&lt;p&gt;The final step is confirming the plumbing — environment variables, feature flags, configuration values — actually connects the caller to the implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Does the calling code depend on a flag that's never set?&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RenderJob&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'MULTI_PLATFORM_RENDERING_ENABLED'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt; &lt;span class="c1"&gt;# ← Is this set?&lt;/span&gt;
      &lt;span class="no"&gt;VideoRenderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render_for_all_platforms&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="no"&gt;VideoRenderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render_single_platform&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check if the env var is defined anywhere&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"MULTI_PLATFORM_RENDERING_ENABLED"&lt;/span&gt; ./config/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"MULTI_PLATFORM_RENDERING_ENABLED"&lt;/span&gt; ./.env&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"MULTI_PLATFORM_RENDERING_ENABLED"&lt;/span&gt; ./infrastructure/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finding the caller but not the configuration wiring means the feature is structurally present but effectively disabled. Depending on your defaults, this could mean the new code never runs — or worse, always runs when it shouldn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying This to AI Agent Design
&lt;/h2&gt;

&lt;p&gt;If you're building or prompting AI agents to do this kind of audit, the pattern translates directly into a structured task definition. Here's a minimal prompt template for a phase-level audit agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are auditing Phase [N]: [Phase Name].

For each integration point listed in the plan document:
1. Confirm the method/endpoint exists (exact name match)
2. Check its access modifier — is it callable by the expected caller?
3. Search all provided repositories for callers of this method
4. Verify any required configuration (env vars, flags) is wired up

Report findings as JSON with fields:
- integration_point: string
- exists: boolean
- access_modifier: "public" | "private" | "protected" | "unknown"
- callers_found: string[] (file paths)
- config_wired: boolean
- risk: "none" | "low" | "medium" | "high"
- notes: string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Asking the agent to return structured JSON means you can aggregate findings across multiple agents running in parallel on different phases, deduplicate overlapping issues, and prioritise by risk before handing off to human engineers for validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Path Variant: JSONB and Schema Drift
&lt;/h2&gt;

&lt;p&gt;The 4-step pattern works for method-level integrations, but there's a nastier variant: data contract mismatches. In systems that share a database — or where multiple code paths within one application write to the same semi-structured column — schema drift can be silent and painful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Service A writes metadata like this:&lt;/span&gt;
&lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;metadata: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;narration_url: &lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;timing_data: &lt;/span&gt;&lt;span class="n"&gt;timings&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# Service B reads metadata like this:&lt;/span&gt;
&lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tts_url'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# ← Different key, silently returns nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this variant, the verification pattern shifts. Instead of tracing callers, you trace writers and readers of the same data structure. Search for all write paths to the column, all read paths from the column, and map out which keys are written versus which keys are read. Mismatches are your bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Agents in Parallel, Not Sequence
&lt;/h2&gt;

&lt;p&gt;One design choice that can improve coverage: run agents for different phases simultaneously rather than one after another. Sequential auditing risks each agent's findings influencing the framing of the next, and it's just slower.&lt;/p&gt;

&lt;p&gt;Parallel agents produce independent findings that you aggregate afterward. This catches issues that a single sequential pass might rationalise away — if one agent flags a dead method and a separate agent (with no knowledge of that finding) independently identifies that the expected caller has a bug, those two findings together paint a much clearer picture than either does alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Human Validation Is Non-Negotiable
&lt;/h2&gt;

&lt;p&gt;It's worth being direct about this: AI agents surface candidates for issues. They're not a replacement for a human engineer confirming the finding against a live codebase.&lt;/p&gt;

&lt;p&gt;Static search — whether by grep, an AI agent, or an AST tool — has real blind spots: dynamic dispatch, reflection, metaprogramming, framework conventions that invoke methods by naming pattern, generated clients, runtime config injection, and external callers that live outside the repos you searched. Any of these can make reachable code look unreachable, or vice versa.&lt;/p&gt;

&lt;p&gt;Treat agent output as a prioritised investigation list, not a definitive bug report. The value is in the structured, cross-repo coverage — catching the categories of issues that would slip through — not in the infallibility of each individual finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Your Own Cross-Repo Audit
&lt;/h2&gt;

&lt;p&gt;You don't need a sophisticated AI setup to apply this pattern today. Even a rough script that runs the first few verification steps across repos will surface issues. Here's an illustrative starting point (not production-grade, but enough to demonstrate the approach):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="c"&gt;# Usage: ./audit_integration.sh render_for_all_platforms ./cms ./analytics ./renderer&lt;/span&gt;

&lt;span class="nv"&gt;METHOD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?method&lt;span class="p"&gt; name required&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;shift

printf&lt;/span&gt; &lt;span class="s1"&gt;'=== Auditing: %s ===\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$METHOD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;REPO &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\n--- Searching in %s ---\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="c"&gt;# Step 1: Definitions&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'Definitions:\n'&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.rb'&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"def[[:space:]]+&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;METHOD&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
  &lt;span class="c"&gt;# Step 3: References (excluding specs)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'References:\n'&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.rb'&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;METHOD&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'(_spec\.rb|/spec/)'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is obviously a starting point — you'd want to add access modifier parsing, config checking, and structured output for aggregation. But it demonstrates the core idea: systematically checking existence and reachability across repo boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payoff
&lt;/h2&gt;

&lt;p&gt;The methodology described here found ten real issues in one internal audit — including a fully-implemented feature that was never invoked. That's not a minor edge case catch; that's weeks of engineering work sitting invisible in production.&lt;/p&gt;

&lt;p&gt;Integration boundary bugs are expensive because they're hard to find and easy to miss at review time. A structured, phase-level verification approach — whether run by AI agents or methodical engineers — is one of the more effective ways to catch this class of issue before it reaches users.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article builds on a deeper exploration at &lt;a href="https://www.devgab.com/a/cross-project-auditing-with-parallel-ai-agents/" rel="noopener noreferrer"&gt;devgab.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you run into integration bugs that slipped through code review because the gap lived between PRs?&lt;/strong&gt; What's your current approach to auditing work that spans multiple repos — and would you trust an AI agent to surface those gaps?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>codereview</category>
      <category>devops</category>
    </item>
    <item>
      <title>Unpopular Opinion: Most Webhook Implementations Are Dangerously Half-Baked</title>
      <dc:creator>DevGab</dc:creator>
      <pubDate>Wed, 18 Mar 2026 03:52:31 +0000</pubDate>
      <link>https://dev.to/devgab/unpopular-opinion-most-webhook-implementations-are-dangerously-half-baked-5d04</link>
      <guid>https://dev.to/devgab/unpopular-opinion-most-webhook-implementations-are-dangerously-half-baked-5d04</guid>
      <description>&lt;p&gt;There. I said it.&lt;/p&gt;

&lt;p&gt;After reviewing dozens of Rails codebases over the years, I've come to a uncomfortable conclusion: webhook implementations are almost universally treated as an afterthought. We copy-paste a Stripe tutorial, slap a &lt;code&gt;before_action :verify_signature&lt;/code&gt; on a controller, and call it production-ready. It's not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Just Receive It" Mentality Is Costing Us
&lt;/h2&gt;

&lt;p&gt;Ask most developers what their webhook setup looks like and you'll hear some version of: &lt;em&gt;"Oh, we have an endpoint that Stripe hits and we process the event."&lt;/em&gt; That's it. No mention of retry handling. No idempotency. No audit trail. Definitely no outbound webhooks.&lt;/p&gt;

&lt;p&gt;This matters because real production systems are almost never one-directional. You receive events from Stripe, yes — but you're also probably &lt;em&gt;sending&lt;/em&gt; events to your partners, your analytics pipeline, your fulfillment vendor. That outbound flow gets built as a hastily-assembled &lt;code&gt;HTTParty.post&lt;/code&gt; buried in an ActiveRecord callback somewhere. And then it silently fails on a Tuesday at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inbound ≠ Outbound, and Treating Them the Same Is the Root Problem
&lt;/h2&gt;

&lt;p&gt;Here's the thing that took me an embarrassingly long time to internalize: receiving and sending webhooks are architecturally opposite problems, even though they look similar on the surface.&lt;/p&gt;

&lt;p&gt;When you &lt;strong&gt;receive&lt;/strong&gt; a webhook, you don't control the retry policy. You don't control when events arrive. You don't control what happens if you return a 500. The sender might retry three times, or thirty times, or never again — and you won't know until orders stop processing. Your job is to be fast, forgiving, and idempotent. Acknowledge first, process later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This is the correct pattern for inbound webhooks.&lt;/span&gt;
&lt;span class="c1"&gt;# Notice what it does NOT do: heavy processing inline.&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:unauthorized&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;valid_signature?&lt;/span&gt;

  &lt;span class="c1"&gt;# Acknowledge immediately — do NOT make the sender wait&lt;/span&gt;
  &lt;span class="no"&gt;WebhookProcessorJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw_post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;provider: &lt;/span&gt;&lt;span class="s1"&gt;'stripe'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you &lt;strong&gt;send&lt;/strong&gt; a webhook, the calculus flips entirely. Now you're responsible for the retry strategy. A network timeout is a transient failure — retry it with backoff. A 400 Bad Request means your payload is wrong — retrying it a hundred times won't fix that. These are fundamentally different failure modes that need different responses, and conflating them leads to both missed deliveries and infinite retry loops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OutboundWebhookJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="c1"&gt;# Transient errors (timeouts, 5xx): retry with backoff&lt;/span&gt;
  &lt;span class="n"&gt;retry_on&lt;/span&gt; &lt;span class="no"&gt;WebhookService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NetworkError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;wait: :polynomially_longer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;attempts: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;

  &lt;span class="c1"&gt;# Permanent errors (4xx, bad payload): stop immediately&lt;/span&gt;
  &lt;span class="n"&gt;discard_on&lt;/span&gt; &lt;span class="no"&gt;WebhookService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BadPayloadError&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;WebhookService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deliver!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  "But We Use Sidekiq Retries" Doesn't Cut It
&lt;/h2&gt;

&lt;p&gt;I hear this one a lot. Yes, Sidekiq retries are great. No, they are not a substitute for intentional error handling in your webhook layer. Sidekiq doesn't know the difference between a 408 timeout and a 422 validation error — it just retries both. That means you're hammering a partner endpoint with a malformed payload 25 times, generating noise in their logs and potentially getting your IP rate-limited or blocked.&lt;/p&gt;

&lt;p&gt;Smart retry logic needs to live in your application code, not be delegated entirely to your job queue's default behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Asymmetry Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Here's another asymmetry that rarely gets called out explicitly: inbound and outbound webhooks use the &lt;em&gt;same cryptographic primitive&lt;/em&gt; (HMAC-SHA256 is pretty much the industry standard) but with completely opposite trust models.&lt;/p&gt;

&lt;p&gt;For inbound webhooks, you receive someone else's signature and verify it against a shared secret &lt;em&gt;they&lt;/em&gt; gave you. For outbound webhooks, you sign your own payloads with a secret &lt;em&gt;you&lt;/em&gt; gave to your receiver. Same algorithm, opposite direction of trust. If you build a generic "webhook signature" utility without accounting for this, you'll end up with subtle security bugs or, worse, a system where you accidentally expose your own signing key to the wrong party.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Production-Ready" Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;I'd argue that a truly production-ready webhook system needs at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys&lt;/strong&gt; on inbound processing — deduplicate events before acting on them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A polymorphic audit log&lt;/strong&gt; for both directions — you need to answer "what did we receive, and what did we send, and when?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Differentiated retry logic&lt;/strong&gt; on outbound — transient vs. permanent failures are not the same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent secret rotation&lt;/strong&gt; per integration — one compromised partner shouldn't require you to rotate everything&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts on outbound requests&lt;/strong&gt; — seriously, don't let a slow partner endpoint block your job queue workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most apps I've seen have maybe two of these five. Some have none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Do We Keep Getting This Wrong?
&lt;/h2&gt;

&lt;p&gt;Honestly? I think it's because webhooks feel simple. It's just HTTP, right? POST to an endpoint, return 200, done. The failure modes are invisible — silent data loss doesn't throw an exception you can see in your error tracker. An outbound webhook that fails with no retry never shows up as an error unless you're explicitly monitoring for it.&lt;/p&gt;

&lt;p&gt;We've also been conditioned by the Stripe integration tutorial, which is genuinely excellent but only covers one direction and one provider. It's a starting point, not a template for your entire webhook architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Stop treating webhooks as a solved problem. Start treating them as an integration boundary that deserves the same architectural rigor you'd give to any other critical data pipeline. Model the two directions separately. Make failure explicit. Build the audit trail. Your on-call rotation will thank you.&lt;/p&gt;

&lt;p&gt;Webhooks are not hard to get right — but they require intentionality that most tutorials never bother to teach.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article builds on a deeper exploration at &lt;a href="https://www.devgab.com/a/building-resilient-webhook-systems/" rel="noopener noreferrer"&gt;devgab.com — Building Resilient Webhook Systems: A Tale of Two Directions&lt;/a&gt;, which includes full implementation details for signature verification, polymorphic audit trails, and retry strategies in Rails.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What does your webhook setup look like in production?&lt;/strong&gt; Are you handling both directions explicitly, or is outbound still a &lt;code&gt;Net::HTTP.post&lt;/code&gt; in a model somewhere? Drop your setup (or your horror stories) in the comments — genuinely curious where the community is at on this.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>discuss</category>
      <category>rails</category>
    </item>
    <item>
      <title>Rails' Four-Layer Contract: Why Every Feature Needs a Route, Policy, Controller, AND Model Method</title>
      <dc:creator>DevGab</dc:creator>
      <pubDate>Tue, 17 Mar 2026 21:22:48 +0000</pubDate>
      <link>https://dev.to/devgab/rails-four-layer-contract-why-every-feature-needs-a-route-policy-controller-and-model-method-3bmm</link>
      <guid>https://dev.to/devgab/rails-four-layer-contract-why-every-feature-needs-a-route-policy-controller-and-model-method-3bmm</guid>
      <description>&lt;p&gt;Here's a scenario that should terrify you: a user clicks a button, sees no error, and walks away assuming their action succeeded. Meanwhile, on the server, something quietly did nothing — or worse, did the &lt;em&gt;wrong&lt;/em&gt; thing.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical. It's what happens when you add a UI element to a Rails app without completing what I call the &lt;strong&gt;four-layer contract&lt;/strong&gt; : Route → Policy → Controller → Model. Miss any single layer, and you get silent failures that won't show up in your logs or error trackers.&lt;/p&gt;

&lt;p&gt;Let's break this down layer by layer, look at exactly how each one fails, and build a checklist you can use every time you add a new action to your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Layers, Visualized
&lt;/h2&gt;

&lt;p&gt;Every user-initiated action in a Rails app travels through this chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser Request
       │
       ▼
┌─────────────┐
│    Route    │ Does this URL + verb map to an action?
└──────┬──────┘
       │
       ▼
┌─────────────┐
│    Policy   │ Is this user allowed to do this?
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  Controller │ Orchestrate: parse params, call model, render
└──────┬──────┘
       │
       ▼
┌─────────────┐
│    Model    │ Execute the actual business logic
└─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical thing to understand: &lt;strong&gt;each layer fails differently&lt;/strong&gt;. Some fail loudly (a 500 error), some fail silently (a 403 you never see), and some fail invisibly (data gets saved but wrong). Let's go through each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: The Route
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happens when it's missing
&lt;/h3&gt;

&lt;p&gt;If you reference a named route in a view that doesn't exist in &lt;code&gt;routes.rb&lt;/code&gt;, Rails raises a &lt;code&gt;NoMethodError&lt;/code&gt; on the &lt;em&gt;route helper itself&lt;/em&gt; — but only when the view renders. If the button is conditionally rendered (e.g., only for certain roles), this can stay hidden in development and only surface in production for specific users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# This line will raise NoMethodError if the route doesn't exist %&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;button_to&lt;/span&gt; &lt;span class="s2"&gt;"Submit for Review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transition_to_review_workflow_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sneakier failure is a copy-paste error: you meant to write &lt;code&gt;transition_to_review_workflow_path&lt;/code&gt; but pasted &lt;code&gt;transition_to_approved_workflow_path&lt;/code&gt; instead. Both are valid helper methods, no error is raised, but your button now triggers the wrong action entirely. No exception anywhere — just a workflow that silently does the wrong thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/routes.rb&lt;/span&gt;
&lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="ss"&gt;:workflows&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:approve&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:reject&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:transition_to_review&lt;/span&gt; &lt;span class="c1"&gt;# ← add this explicitly&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;rails routes | grep workflow&lt;/code&gt; after every new action to verify the route exists before writing any other code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: The Policy (Pundit)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happens when it's missing
&lt;/h3&gt;

&lt;p&gt;This one catches people off guard. If you're using Pundit and your policy class doesn't define a &lt;code&gt;transition_to_review?&lt;/code&gt; method, you get a &lt;code&gt;NoMethodError&lt;/code&gt; — a 500, not a 403. That's actually the &lt;em&gt;good&lt;/em&gt; failure mode because it's loud and obvious.&lt;/p&gt;

&lt;p&gt;The truly devious failure is when the policy method &lt;em&gt;exists&lt;/em&gt; but has the wrong logic — typically from copying another method and forgetting to update the conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/policies/workflow_policy.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkflowPolicy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationPolicy&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approve?&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admin?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reject?&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admin?&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reviewer?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# Copied from approve? but forgot to update the conditions&lt;/span&gt;
  &lt;span class="c1"&gt;# Authors should be able to submit for review, but this only allows admins&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition_to_review?&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admin?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your authors click "Submit for Review" and get redirected with a "You're not authorised" flash message. The bug gets filed as a permissions issue. Someone spends an hour checking role assignments before realising the policy method is simply wrong. Meanwhile, a missing method would have pointed directly to the problem in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/policies/workflow_policy.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkflowPolicy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationPolicy&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition_to_review?&lt;/span&gt;
    &lt;span class="c1"&gt;# Be explicit about who can trigger this action&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author?&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draft?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every route you add in &lt;code&gt;routes.rb&lt;/code&gt; should have a corresponding policy method with its own logic — never copy from another method without reviewing the conditions. Make this a code review requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: The Controller Action
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happens when it's missing (or wrong)
&lt;/h3&gt;

&lt;p&gt;A missing controller action raises a &lt;code&gt;AbstractController::ActionNotFound&lt;/code&gt; — this one is at least loud. But the silent failure mode is more interesting: the controller action exists but reads the &lt;strong&gt;wrong param key&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What the form sends:&lt;/span&gt;
&lt;span class="c1"&gt;# { "notes" =&amp;gt; "Ready for review", "record_id" =&amp;gt; "42" }&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition_to_review&lt;/span&gt;
  &lt;span class="n"&gt;authorize&lt;/span&gt; &lt;span class="vi"&gt;@record&lt;/span&gt;

  &lt;span class="c1"&gt;# BUG: reads :transition_notes, but form sends :notes&lt;/span&gt;
  &lt;span class="n"&gt;note_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:transition_notes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="vi"&gt;@record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transition_to_review!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;note: &lt;/span&gt;&lt;span class="n"&gt;note_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"Submitted for review."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;note_text&lt;/code&gt; is &lt;code&gt;nil&lt;/code&gt;. The record saves. The audit log has blank notes. No exception is raised. This is the category of bug that takes 3 hours to track down because everything &lt;em&gt;appears&lt;/em&gt; to work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: use Strong Parameters defensively
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition_to_review&lt;/span&gt;
  &lt;span class="n"&gt;authorize&lt;/span&gt; &lt;span class="vi"&gt;@record&lt;/span&gt;

  &lt;span class="c1"&gt;# Explicitly permit and alias if needed&lt;/span&gt;
  &lt;span class="n"&gt;transition_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:workflow&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:notes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="vi"&gt;@record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transition_to_review!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;note: &lt;/span&gt;&lt;span class="n"&gt;transition_params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:notes&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"Submitted for review."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="kp"&gt;private&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_record&lt;/span&gt;
  &lt;span class="vi"&gt;@record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;params.require()&lt;/code&gt; means Rails will raise an &lt;code&gt;ActionController::ParameterMissing&lt;/code&gt; error if the top-level key is absent — making the contract between form and controller explicit and enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: The Model Method
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happens when it's missing
&lt;/h3&gt;

&lt;p&gt;This is the most visible failure: a clean &lt;code&gt;NoMethodError&lt;/code&gt; pointing directly at the missing method. Celebrate this one — it's honest. The problem is what comes &lt;em&gt;after&lt;/em&gt; you add the method without thinking about side effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The naive implementation&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition_to_review!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
  &lt;span class="n"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :pending_review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;review_notes: &lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you also have a &lt;code&gt;before_update&lt;/code&gt; callback that creates audit records on status changes, you now have two code paths both writing audit records. The controller might also manually build an audit record. Result: every transition creates duplicate audit entries, and your audit trail is permanently corrupted.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: make the model method the single source of truth
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/workflow.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition_to_review!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
  &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :pending_review&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;audit_records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;action: :transition_to_review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;notes: &lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;performed_by: &lt;/span&gt;&lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remove any duplicate audit logic from callbacks or controllers. One method, one responsibility, one writer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four-Layer Checklist
&lt;/h2&gt;

&lt;p&gt;Print this out and tape it to your monitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;New Rails Action Checklist
==========================

□ Route added to routes.rb
  → Run: rails routes | grep &amp;lt;action_name&amp;gt;

□ Policy method defined
  → Matches exact action name: def &amp;lt;action_name&amp;gt;?
  → Tested with correct and incorrect user roles

□ Controller action implemented
  → Params read with correct keys (verify against form field names)
  → authorize called before any data mutation
  → Single exit path (no branching redirects without coverage)

□ Model method implemented
  → Wrapped in transaction if multiple writes
  → Audit record written in ONE place only
  → Callbacks audited for conflicts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Catching This Class of Bug Earlier
&lt;/h2&gt;

&lt;p&gt;Beyond the checklist, there are two Rails-native techniques that surface layer mismatches before production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Route-aware integration tests.&lt;/strong&gt; Write a request spec for every new action that asserts a 200 (or redirect) response. A missing route causes the spec to fail immediately rather than waiting for a user report.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/requests/workflows_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"Workflows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :request&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"POST /workflows/:id/transition_to_review"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"transitions the workflow and returns a redirect"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;sign_in&lt;/span&gt; &lt;span class="n"&gt;author_user&lt;/span&gt;
      &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="n"&gt;transition_to_review_workflow_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;workflow: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;notes: &lt;/span&gt;&lt;span class="s2"&gt;"Ready for review"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;redirect_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pending_review"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Policy spec coverage for every action.&lt;/strong&gt; Using &lt;code&gt;pundit-matchers&lt;/code&gt;, write a spec that explicitly asserts which roles can and cannot access each action. A missing policy method causes the spec to fail loudly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/policies/workflow_policy_spec.rb&lt;/span&gt;
&lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;WorkflowPolicy&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="s2"&gt;"transition_to_review"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="s2"&gt;"when user is author and workflow is draft"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:workflow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;author: &lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;is_expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;permit_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:transition_to_review&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="s2"&gt;"when user is a reviewer"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:reviewer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;is_expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;forbid_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:transition_to_review&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Mental Model Shift
&lt;/h2&gt;

&lt;p&gt;The real lesson here isn't about Rails mechanics — it's about how we think about "done." When a designer updates a workflow UI and the button looks right in the browser, it &lt;em&gt;feels&lt;/em&gt; done. But the UI is just a promise. The backend is where you keep it.&lt;/p&gt;

&lt;p&gt;Every new button, every new form, every new link is really four tasks masquerading as one. Treat it that way, and silent failures become a lot rarer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article builds on a deeper exploration at &lt;a href="https://www.devgab.com/a/hidden-costs-of-ui-first-development/" rel="noopener noreferrer"&gt;The Hidden Costs of UI-First Development: A Rails Workflow Bug Postmortem&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's your approach to preventing this kind of layer mismatch? Do you use code generation, templates, or strict PR checklists? I'd love to hear how your team handles it. 👇&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
