<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Prateek Codes - Building Scalable Backend Systems</title>
    <description>Learn how to build scalable backend systems with Ruby on Rails, PostgreSQL optimization, database scaling, and practical engineering insights from real-world production experiences.</description>
    <link>https://prateekcodes.com/</link>
    <atom:link href="https://prateekcodes.com/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Thu, 14 May 2026 23:01:04 +0000</pubDate>
    <lastBuildDate>Thu, 14 May 2026 23:01:04 +0000</lastBuildDate>
    <generator>Jekyll v4.4.1</generator>
    
      <item>
        <title>Multi-hop delegation for AI agents: porting OAuth&apos;s on-behalf-of pattern into MCP topologies</title>
        <description>&lt;p&gt;Most AI agent deployments hit an awkward authentication problem inherited from web auth. A user authenticates. The user invokes an agent. The agent calls a tool. The tool calls another tool. By the time the request reaches the third service down, the identity of the original principal has either been lost, forged, or smuggled forward as a bearer credential that the agent could just as easily exfiltrate. The naive solutions are familiar. Hand the agent the user’s access token, and it leaks the first time a prompt injection succeeds. Give the agent its own credential, and downstream services lose user attribution. Pass a custom header full of user claims, and the claims have no cryptographic binding to the original session.&lt;/p&gt;

&lt;p&gt;The IAM community already has the right pattern. It is &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8693&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;RFC 8693 Token Exchange&lt;/a&gt; and the on-behalf-of grant flow it formalizes. Several IETF drafts now extend that flow specifically for agent topologies. This post is about that pattern.&lt;/p&gt;

&lt;h2 id=&quot;introducing-delegated-authorization-with-actor-chains&quot;&gt;Introducing: delegated authorization with actor chains&lt;/h2&gt;

&lt;p&gt;Delegation is the old IAM word for “principal A authorizes principal B to act for them, with some bounded scope.” Impersonation is the variant where the downstream service cannot tell whether A or B made the call. Delegation is the variant where it can. For agent topologies, the second is the only acceptable design.&lt;/p&gt;

&lt;p&gt;RFC 8693 Token Exchange formalizes both. The relevant primitives are two token parameters: &lt;code&gt;subject_token&lt;/code&gt; (the principal the request is being made for) and &lt;code&gt;actor_token&lt;/code&gt; (the principal making the request on their behalf). When an agent calls a tool, the token it presents has the user as subject and the agent itself as actor. When that tool, in turn, calls another tool, the second tool receives a token whose subject is still the user, whose immediate actor is the first tool, and whose actor’s actor is the agent. The chain is preserved.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;act&lt;/code&gt; claim is what makes this work. It is a nested JWT claim whose value is itself an actor description, recursively. A four-hop call carries four-deep actor nesting. The token is no longer “a bearer credential that names a principal.” It is a transcript of who delegated to whom, signed by the issuer, that any downstream verifier can interpret.&lt;/p&gt;

&lt;p&gt;This inverts the trust direction. In a bearer-token world, every downstream service has to trust that the immediate caller is legitimately the principal it claims to be. In an actor-chain world, every downstream service can verify the entire chain of delegation from issuer-signed claims. The agent does not need the user’s secret. The agent has its own credential, presented alongside a delegation token that binds it to a specific user context.&lt;/p&gt;

&lt;h2 id=&quot;why-bearer-tokens-fail-in-agent-topologies&quot;&gt;Why bearer tokens fail in agent topologies&lt;/h2&gt;

&lt;p&gt;Three failure modes show up immediately when bearer credentials are reused across composed agent tool calls. Each is recognizable from older IAM experience. Each is amplified by the probabilistic nature of agent behavior.&lt;/p&gt;

&lt;p&gt;Token exfiltration through prompt injection. If the agent holds the user’s bearer token, the agent can be coerced into sending it somewhere. Prompt injection is the agent-shaped variant of an attack that web-IAM solved by binding tokens to channels (cookies with &lt;code&gt;HttpOnly&lt;/code&gt;, proof-of-possession tokens, mTLS-bound sessions). Bearer tokens in the agent’s working context are stored, transmitted, and reasoned about by a language model that does not have &lt;code&gt;HttpOnly&lt;/code&gt;. The mitigation is structural: the agent must not hold a credential that grants it the user’s authority. It must hold a credential that grants it the right to ask, on the user’s behalf, with the issuer mediating each ask.&lt;/p&gt;

&lt;p&gt;Audit blindness. When a downstream service sees a bearer token whose subject is the user, the audit log can only record “the user did this.” If the call was actually initiated by an agent acting for the user, that distinction is missing from the record. Post-incident forensics on agent activity then becomes impossible: every agent action looks like a user action, and the principal-of-record for the call is wrong. The actor chain fixes this because it records, in the token itself, which agent invoked which tool, and the audit log can capture it without trusting the agent’s self-report.&lt;/p&gt;

&lt;p&gt;Cross-tool replay. Bearer tokens are valid wherever the issuer is trusted. An agent that received a token for one tool can present the same token to a different tool if that tool’s verifier accepts the same issuer. &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8707&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;RFC 8707 Resource Indicators&lt;/a&gt; address part of this by binding tokens to specific audiences. RFC 8693 addresses the rest by ensuring that each hop receives a fresh token narrowed to its audience and scope, derived from but not equal to the token the previous hop received. Replay across the topology becomes a token forgery problem, not a token reuse problem.&lt;/p&gt;

&lt;h2 id=&quot;what-the-actor-chain-looks-like&quot;&gt;What the actor chain looks like&lt;/h2&gt;

&lt;p&gt;The mechanics are clarified by walking through a single exchange. An agent has just received a request from a user. The agent has its own credential (the agent assertion). To call a downstream tool on the user’s behalf, the agent presents both: its own credential as &lt;code&gt;actor_token&lt;/code&gt;, and the user’s authorization assertion as &lt;code&gt;subject_token&lt;/code&gt;. The authorization server returns a new access token whose subject is the user and whose immediate actor (the &lt;code&gt;act&lt;/code&gt; claim) is the agent.&lt;/p&gt;

&lt;p&gt;When that tool in turn needs to call another tool, it performs another exchange. It presents its own credential as &lt;code&gt;actor_token&lt;/code&gt;. It presents the token it just received as &lt;code&gt;subject_token&lt;/code&gt;. The authorization server returns a token whose subject is still the user, whose immediate actor is the tool, and whose actor’s actor is the original agent. The claim structure looks like this:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;iss&quot;: &quot;https://auth.example.com&quot;,
  &quot;aud&quot;: &quot;tool_b&quot;,
  &quot;sub&quot;: &quot;user:alice&quot;,
  &quot;act&quot;: {
    &quot;sub&quot;: &quot;tool_a&quot;,
    &quot;act&quot;: {
      &quot;sub&quot;: &quot;agent:session-7f3a&quot;
    }
  },
  &quot;scope&quot;: &quot;orders:read&quot;,
  &quot;exp&quot;: 1715800000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That structure is the delegation history of the request, signed by the issuer, verifiable by any party that trusts the issuer’s keys. The downstream verifier knows three things at once: who the action is ultimately for, who the most recent actor was, and the full chain of actors that brought the request to this point. The authorization policy at each hop can use any of those claims, individually or in combination, as inputs to its decision.&lt;/p&gt;

&lt;h2 id=&quot;what-this-fixes-and-what-it-does-not&quot;&gt;What this fixes, and what it does not&lt;/h2&gt;

&lt;p&gt;The chain pattern fixes the three failure modes named earlier, and a couple more besides.&lt;/p&gt;

&lt;p&gt;Attribution becomes precise. Every audit record at every hop names the user, the agent, and the intermediate tools. Post-incident analysis stops being guesswork. Scoped delegation per hop becomes possible: the token exchange at each hop can narrow scope. An agent granted broad read access by the user can still choose to invoke only &lt;code&gt;orders:read&lt;/code&gt; against tool A; the narrowing is recorded in the token A receives. If A then calls B, A can narrow further. The narrowing is monotonic, since downstream tokens cannot widen what upstream tokens granted. Revocation becomes selective: revoking the agent’s authority does not require revoking the user’s session, and revoking one tool’s authority does not require revoking the agent.&lt;/p&gt;

&lt;p&gt;What this pattern does not fix is worth naming.&lt;/p&gt;

&lt;p&gt;It does not verify agent intent. The user authorized the agent in general, not for this specific action. If the agent’s interpretation of the user’s request is wrong, every token in the chain is correctly issued for a request the user did not actually want. Step-up authorization is the answer there, and it is a separate problem.&lt;/p&gt;

&lt;p&gt;It does not replace the policy decision. The actor chain is input to authorization, not output. Each hop still needs an RBAC or ABAC decision based on the claims in the token. Token exchange is a transport mechanism for delegation context, not a policy engine.&lt;/p&gt;

&lt;h2 id=&quot;the-state-of-the-standards-work&quot;&gt;The state of the standards work&lt;/h2&gt;

&lt;p&gt;The IETF work is moving, with several drafts active. &lt;a href=&quot;https://datatracker.ietf.org/doc/html/draft-oauth-ai-agents-on-behalf-of-user-02&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;code&gt;draft-oauth-ai-agents-on-behalf-of-user-02&lt;/code&gt;&lt;/a&gt; (published August 2025) extends RFC 8693 with &lt;code&gt;requested_actor&lt;/code&gt; and &lt;code&gt;actor_token&lt;/code&gt; parameters specifically scoped to agent flows. It is the most direct port of the on-behalf-of pattern for MCP-shaped topologies. &lt;a href=&quot;https://www.ietf.org/archive/id/draft-rosenberg-oauth-aauth-00.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;code&gt;draft-rosenberg-oauth-aauth-00&lt;/code&gt;&lt;/a&gt; (“AAuth: Agentic Authorization OAuth 2.1 Extension”) addresses a different slice: a grant flow for agents operating in voice, SMS, or messaging channels where the browser redirect at the heart of standard OAuth is not available. The two drafts are not competing so much as partitioning the problem space.&lt;/p&gt;

&lt;p&gt;Inside the on-behalf-of slice specifically, the live question is how much new machinery is needed. One position holds that RFC 8693 already covers the essential semantics, and what is needed is profile-level guidance: standard claim shapes, conventions for naming agent principals, sensible defaults for token lifetime in agent contexts. The other position argues for explicit new parameters, on the basis that the assumptions in RFC 8693 (especially around how &lt;code&gt;actor_token&lt;/code&gt; is obtained for a non-human actor) do not map cleanly onto stochastic agent sessions.&lt;/p&gt;

&lt;p&gt;Either way, the direction is settled. Multi-hop delegation for agents will be expressed in the language of RFC 8693, with or without a profile draft on top. Reading the drafts now, rather than after the WG adopts one, is the cheaper option.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;On-behalf-of is the second IAM lesson worth porting into agent terms, after &lt;a href=&quot;/read-only-database-mcps-scoped-delegation-iam&quot; target=&quot;_blank&quot;&gt;scoped delegation&lt;/a&gt;. Together they cover most of what an MCP authorization design needs to get right at the protocol layer. The remaining lessons (sender-constrained tokens via DPoP, workload identity via SPIFFE-style attestation, step-up authorization through out-of-band re-auth) extend the pattern further but do not change its shape. The shape was decided in January 2020 when RFC 8693 was published. Agent authorization is, mostly, a question of adopting it deliberately rather than reinventing parts of it accidentally.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8693&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;RFC 8693 - OAuth 2.0 Token Exchange&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8707&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;RFC 8707 - Resource Indicators for OAuth 2.0&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/draft-oauth-ai-agents-on-behalf-of-user-02&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;draft-oauth-ai-agents-on-behalf-of-user-02&lt;/a&gt; - IETF draft extending RFC 8693 for agent flows&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.ietf.org/archive/id/draft-rosenberg-oauth-aauth-00.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;AAuth: Agentic Authorization OAuth 2.1 Extension&lt;/a&gt; - competing IETF draft with broader scope&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://modelcontextprotocol.io/specification/draft/basic/authorization&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Model Context Protocol authorization specification&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://workos.com/blog/oauth-multi-hop-delegation-ai-agents&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;The multi-hop delegation problem for AI agents&lt;/a&gt; - explainer on the bearer-token failure mode&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/read-only-database-mcps-scoped-delegation-iam&quot; target=&quot;_blank&quot;&gt;Read-only database MCPs as a scoped-delegation pattern&lt;/a&gt; - prior post on the RBAC/capability-surface half of the problem&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Tue, 07 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/multi-hop-delegation-oauth-on-behalf-of-ai-agents/</link>
        <guid isPermaLink="true">https://prateekcodes.com/multi-hop-delegation-oauth-on-behalf-of-ai-agents/</guid>
        
        <category>mcp</category>
        
        <category>ai-agents</category>
        
        <category>iam</category>
        
        <category>oauth</category>
        
        <category>rfc-8693</category>
        
        <category>token-exchange</category>
        
        <category>delegation</category>
        
        <category>on-behalf-of</category>
        
        
        <category>AI</category>
        
        <category>MCP</category>
        
        <category>Security</category>
        
        <category>Identity</category>
        
      </item>
    
      <item>
        <title>Stop Pasting Schema Into Your AI: Connect PostgreSQL Directly with MCP</title>
        <description>&lt;p&gt;Agentic coding tools have gotten good at reading your codebase. Claude Code will find your &lt;code&gt;schema.rb&lt;/code&gt;, Cursor will pick up your Prisma schema, and most tools know how to navigate ORM-based projects well enough to understand your data model structurally.&lt;/p&gt;

&lt;p&gt;What they can’t do is reason about your actual data - and that gap matters more than most developers realize.&lt;/p&gt;

&lt;p&gt;When you ask an AI to help design a new feature, it’s working from structure alone. It knows what columns exist, not how they’re used. It doesn’t know that your &lt;code&gt;notifications&lt;/code&gt; table has 400M rows and any fan-out design will be a problem. It doesn’t know that 80% of your &lt;code&gt;users&lt;/code&gt; have never set a &lt;code&gt;preferences&lt;/code&gt; value, which changes how you’d model the feature. It doesn’t know whether a background job is necessary or whether the data volume makes a synchronous approach fine. These are the tradeoffs that determine whether a feature ships well or causes incidents - and without live data access, the AI is guessing.&lt;/p&gt;

&lt;p&gt;The same gap shows up in debugging. When a query is slow, the AI has to ask you to run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and paste the results. When it needs row counts or value distributions, it asks you to run those too. You become the relay between the AI and your database.&lt;/p&gt;

&lt;p&gt;Model Context Protocol (MCP) is an open standard that lets AI tools connect to external systems and query them directly. With a Postgres MCP server configured, your AI can inspect live data volumes, run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, check value distributions, and make recommendations grounded in what’s actually in your database.&lt;/p&gt;

&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;Here’s what designing a new feature looks like without MCP:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; “I want to add a real-time notification feed for users.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “How many users do you have, and roughly how many notifications per user per day?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; &lt;em&gt;(checks, pastes numbers)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “And what does the current &lt;code&gt;notifications&lt;/code&gt; table look like? How many rows total?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; &lt;em&gt;(runs &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;, pastes result)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “What’s the read pattern - are users mostly reading recent notifications or querying by type?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; &lt;em&gt;(runs a distribution query, pastes result)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI knows your schema. It has no idea what’s in it. Every data-dependent decision becomes a round-trip where you run the query and report back. For a non-trivial feature this happens five or ten times before you get a recommendation you can act on.&lt;/p&gt;

&lt;h2 id=&quot;with-a-postgres-mcp-server&quot;&gt;With a Postgres MCP Server&lt;/h2&gt;

&lt;p&gt;Once connected, the same conversation looks like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; “I want to add a real-time notification feed for users.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; &lt;em&gt;(checks &lt;code&gt;notifications&lt;/code&gt; table row count, queries user and notification distribution, inspects existing indexes)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “You have 400M notification rows across 2.1M users, with a long tail - the top 5% of users account for 60% of rows. A naive fan-out on write will be a problem at this scale. I’d recommend a pull-based feed with a covering index on &lt;code&gt;(user_id, created_at DESC)&lt;/code&gt; rather than pre-computing feeds. Here’s the schema and the background job design.”&lt;/p&gt;

&lt;p&gt;No relay. The AI queries your database when it needs data and gives you a recommendation grounded in actual numbers. The same applies to debugging - understanding &lt;a href=&quot;/postgresql-explain-analyze-deep-dive/&quot;&gt;query plans&lt;/a&gt; becomes something the AI does directly rather than something you transcribe into the conversation.&lt;/p&gt;

&lt;h2 id=&quot;setting-up-modelcontextprotocolserver-postgres&quot;&gt;Setting Up @modelcontextprotocol/server-postgres&lt;/h2&gt;

&lt;p&gt;The official Postgres MCP server is &lt;code&gt;@modelcontextprotocol/server-postgres&lt;/code&gt;. The source repository was archived in May 2025, but the npm package remains functional and is the most straightforward way to get started.&lt;/p&gt;

&lt;h3 id=&quot;step-1-create-a-read-only-database-user&quot;&gt;Step 1: Create a Read-Only Database User&lt;/h3&gt;

&lt;p&gt;Never connect your AI tool using the same credentials your application uses. Create a dedicated user with read-only access:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Create a dedicated user for AI access
CREATE USER ai_readonly WITH PASSWORD &apos;your-secure-password&apos;;

-- Grant connect on the database
GRANT CONNECT ON DATABASE your_database TO ai_readonly;

-- Grant schema usage
GRANT USAGE ON SCHEMA public TO ai_readonly;

-- Grant read-only access to all current tables
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ai_readonly;

-- Ensure future tables are also covered
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO ai_readonly;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This limits blast radius significantly. Worth noting: the MCP server also runs all queries inside a &lt;code&gt;READ ONLY&lt;/code&gt; transaction - the README states this explicitly - so it refuses mutations at the server level regardless of user permissions. The read-only DB user is a second, independent layer of protection. Both should be in place.&lt;/p&gt;

&lt;h3 id=&quot;step-2-configure-claude-desktop-or-claude-code&quot;&gt;Step 2: Configure Claude Desktop or Claude Code&lt;/h3&gt;

&lt;p&gt;For &lt;strong&gt;Claude Desktop&lt;/strong&gt;, edit &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;postgres&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [
        &quot;-y&quot;,
        &quot;@modelcontextprotocol/server-postgres&quot;,
        &quot;postgresql://ai_readonly:your-secure-password@localhost:5432/your_database&quot;
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For &lt;strong&gt;Claude Code&lt;/strong&gt;, open &lt;code&gt;~/.claude/settings.json&lt;/code&gt; and add the same &lt;code&gt;mcpServers&lt;/code&gt; block:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;postgres&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [
        &quot;-y&quot;,
        &quot;@modelcontextprotocol/server-postgres&quot;,
        &quot;postgresql://ai_readonly:your-secure-password@localhost:5432/your_database&quot;
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Restart Claude after saving.&lt;/p&gt;

&lt;h3 id=&quot;step-3-verify-the-connection&quot;&gt;Step 3: Verify the Connection&lt;/h3&gt;

&lt;p&gt;Ask Claude: “What tables are in my database?”&lt;/p&gt;

&lt;p&gt;If it responds with your actual schema, you’re connected. The server exposes two capabilities to the AI:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Schema inspection&lt;/strong&gt; - lists tables, columns, data types, constraints, and indexes&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Query execution&lt;/strong&gt; - runs SQL and returns results into the conversation context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s enough for the AI to write accurate migrations, suggest indexes based on actual table sizes, and debug slow queries by running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; itself.&lt;/p&gt;

&lt;h2 id=&quot;other-mcp-database-options&quot;&gt;Other MCP Database Options&lt;/h2&gt;

&lt;p&gt;The official Postgres server works well for most local setups, but depending on your database host or what you need the AI to do, there are more capable alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://neon.tech/docs/mcp&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Neon MCP documentation (opens in new tab)&quot;&gt;Neon MCP&lt;/a&gt;&lt;/strong&gt; is worth using if you’re on Neon’s serverless Postgres. It’s actively maintained, supports both read and write operations through proper authorization, and integrates branch management. This makes it practical for letting the AI help apply migrations against a staging branch without touching production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase MCP&lt;/strong&gt; ships as part of Supabase’s tooling and gives the AI access to your project’s tables, schema, and Row Level Security policies. Useful if your authorization logic lives in the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite MCP&lt;/strong&gt; (&lt;code&gt;@modelcontextprotocol/server-sqlite&lt;/code&gt;) is the equivalent for local SQLite databases.&lt;/p&gt;

&lt;p&gt;For cloud-hosted databases - RDS, Azure Database, Cloud SQL - the configuration is identical to the local example above. If you’re on a primary-replica setup, point the connection string at the replica rather than the primary.&lt;/p&gt;

&lt;h2 id=&quot;security&quot;&gt;Security&lt;/h2&gt;

&lt;h3 id=&quot;use-a-read-only-connection&quot;&gt;Use a Read-Only Connection&lt;/h3&gt;

&lt;p&gt;Use a dedicated database user with &lt;code&gt;SELECT&lt;/code&gt;-only privileges, as shown in Step 1. The MCP server’s built-in transaction protection is a safety net, not a substitute for database-level access control.&lt;/p&gt;

&lt;h3 id=&quot;zero-data-retention-for-sensitive-databases&quot;&gt;Zero Data Retention for Sensitive Databases&lt;/h3&gt;

&lt;p&gt;If your database contains PII, financial records, or anything you’d classify as sensitive, pay attention to your AI provider’s data retention policy. When the AI queries your database through MCP, the results - including actual row data - pass through the conversation context. That context may be retained by default.&lt;/p&gt;

&lt;p&gt;For sensitive workloads:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Enable zero data retention (ZDR)&lt;/strong&gt; through Anthropic’s API if it’s available on your plan. With ZDR enabled, prompts and outputs aren’t stored or used for model training.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Avoid querying raw PII through the AI&lt;/strong&gt; - have the AI write the query, review it yourself, then run it outside the AI context.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Mask sensitive columns&lt;/strong&gt; using a view that exposes only what the AI needs:&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Expose a masked version of the users table
CREATE VIEW public.users_masked AS
SELECT
  id,
  created_at,
  updated_at,
  role,
  subscription_plan,
  &apos;***&apos; AS email,
  &apos;***&apos; AS phone_number
FROM users;

-- Grant access to the masked view only
GRANT SELECT ON public.users_masked TO ai_readonly;
REVOKE SELECT ON public.users FROM ai_readonly;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The AI still has enough context to answer questions about user behavior, subscription distribution, and counts - without seeing the actual values.&lt;/p&gt;

&lt;h2 id=&quot;what-changes-day-to-day&quot;&gt;What Changes Day to Day&lt;/h2&gt;

&lt;p&gt;Once the MCP server is running, the AI can:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Understand your full schema without you explaining it&lt;/li&gt;
  &lt;li&gt;Suggest indexes based on actual table sizes and existing constraints&lt;/li&gt;
  &lt;li&gt;Write migrations that account for real foreign keys and default values&lt;/li&gt;
  &lt;li&gt;Debug slow queries by running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; itself&lt;/li&gt;
  &lt;li&gt;Answer data questions like “how many orders shipped last week?” directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The manual loop of pasting schema, waiting for a clarifying question, then pasting more schema mostly disappears.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The Postgres MCP server takes under ten minutes to set up, runs all queries in read-only transactions, and removes a class of manual work that compounds fast. Start with a dedicated read-only DB user, be deliberate about what data flows through the AI’s context, and you have a setup that’s practical and secure.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Official postgres MCP server repository on GitHub (opens in new tab)&quot;&gt;@modelcontextprotocol/server-postgres on GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://modelcontextprotocol.io&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Model Context Protocol official documentation (opens in new tab)&quot;&gt;Model Context Protocol documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://neon.tech/docs/mcp&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Neon MCP server documentation (opens in new tab)&quot;&gt;Neon MCP documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/legal/privacy&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Anthropic privacy policy and data retention information (opens in new tab)&quot;&gt;Anthropic privacy policy and data retention&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sun, 05 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/connect-your-ai-to-postgres-with-mcp/</link>
        <guid isPermaLink="true">https://prateekcodes.com/connect-your-ai-to-postgres-with-mcp/</guid>
        
        <category>mcp</category>
        
        <category>postgresql</category>
        
        <category>model-context-protocol</category>
        
        <category>claude</category>
        
        <category>cursor</category>
        
        <category>ai-tools</category>
        
        <category>database</category>
        
        <category>developer-productivity</category>
        
        
        <category>AI</category>
        
        <category>PostgreSQL</category>
        
        <category>MCP</category>
        
        <category>Developer Tools</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 adds this_week?, this_month?, and this_year? to Date and Time</title>
        <description>&lt;p&gt;ActiveSupport already has &lt;code&gt;today?&lt;/code&gt;, &lt;code&gt;yesterday?&lt;/code&gt;, and &lt;code&gt;tomorrow?&lt;/code&gt; on &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Time&lt;/code&gt;. Rails 8.2 adds the next logical set: &lt;code&gt;this_week?&lt;/code&gt;, &lt;code&gt;this_month?&lt;/code&gt;, and &lt;code&gt;this_year?&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;Checking whether a date falls within the current week, month, or year required range comparisons:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Is this order from the current week?
order.placed_at.between?(Time.current.beginning_of_week, Time.current.end_of_week)

# Is this subscription expiring this month?
subscription.expires_on.between?(Date.current.beginning_of_month, Date.current.end_of_month)

# Is this event happening this year?
event.date.year == Date.current.year
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each check is readable enough on its own, but they add up quickly in controllers and views where you are branching on date ranges.&lt;/p&gt;

&lt;h2 id=&quot;rails-82&quot;&gt;Rails 8.2&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55770&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 55770 adding this_week? this_month? this_year? (opens in new tab)&quot;&gt;PR #55770&lt;/a&gt; introduces three new predicate methods on &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Time&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;order.placed_at.this_week?           # true if within the current week
subscription.expires_on.this_month?  # true if within the current month
event.date.this_year?                # true if within the current year
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;They follow the same pattern as the existing predicates:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;Date.current.this_week?   # =&amp;gt; true
Date.current.this_month?  # =&amp;gt; true
Date.current.this_year?   # =&amp;gt; true

Date.yesterday.this_week? # =&amp;gt; true (yesterday is still this week, usually)
Date.current.next_month.this_month? # =&amp;gt; false
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;in-controllers&quot;&gt;In controllers&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;def index
  @orders = Order.all
  @this_week_orders = @orders.select { |o| o.placed_at.this_week? }
  @this_month_orders = @orders.select { |o| o.placed_at.this_month? }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;in-views&quot;&gt;In views&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-erb&quot;&gt;&amp;lt;% if subscription.expires_on.this_month? %&amp;gt;
  &amp;lt;div class=&quot;warning&quot;&amp;gt;Your subscription expires this month.&amp;lt;/div&amp;gt;
&amp;lt;% end %&amp;gt;

&amp;lt;% if report.generated_at.this_week? %&amp;gt;
  &amp;lt;span class=&quot;badge&quot;&amp;gt;Recent&amp;lt;/span&amp;gt;
&amp;lt;% end %&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;in-scopes&quot;&gt;In scopes&lt;/h3&gt;

&lt;p&gt;For database queries, ActiveSupport already provides &lt;code&gt;all_week&lt;/code&gt;, &lt;code&gt;all_month&lt;/code&gt;, and &lt;code&gt;all_year&lt;/code&gt; on &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Time&lt;/code&gt;, which return ranges suitable for use in &lt;code&gt;where&lt;/code&gt; clauses:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Order &amp;lt; ApplicationRecord
  scope :placed_this_week,  -&amp;gt; { where(placed_at: Time.current.all_week) }
  scope :placed_this_month, -&amp;gt; { where(placed_at: Time.current.all_month) }
  scope :placed_this_year,  -&amp;gt; { where(placed_at: Time.current.all_year) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The new predicate methods (&lt;code&gt;this_week?&lt;/code&gt;, &lt;code&gt;this_month?&lt;/code&gt;, &lt;code&gt;this_year?&lt;/code&gt;) complement these scopes when working with already-loaded records in memory rather than filtering at the database level.&lt;/p&gt;

&lt;h2 id=&quot;how-to-change-the-week-boundary&quot;&gt;How to change the week boundary&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;this_week?&lt;/code&gt; uses Monday as the start of the week by default, consistent with ActiveSupport’s &lt;code&gt;beginning_of_week&lt;/code&gt;. If your application configures a different week start, that is respected:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;Date.beginning_of_week = :sunday
Date.current.beginning_of_week  # =&amp;gt; last Sunday
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;this_week?&lt;/code&gt;, &lt;code&gt;this_month?&lt;/code&gt;, and &lt;code&gt;this_year?&lt;/code&gt; are small additions that remove a common category of boilerplate. They complete the set of readable date predicates that ActiveSupport has offered since Rails 3.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55770&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 55770 adding this_week? this_month? this_year? (opens in new tab)&quot;&gt;Pull Request #55770&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://api.rubyonrails.org/classes/ActiveSupport/CoreExt/Date/Calculations.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;ActiveSupport Date Calculations documentation (opens in new tab)&quot;&gt;ActiveSupport Date Calculations documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-82-this-week-this-month-this-year/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-82-this-week-this-month-this-year/</guid>
        
        <category>rails-8-2</category>
        
        <category>activesupport</category>
        
        <category>date</category>
        
        <category>time</category>
        
        <category>predicates</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8.2</category>
        
        <category>ActiveSupport</category>
        
      </item>
    
      <item>
        <title>Read-only database MCPs as a scoped-delegation pattern: applying IAM primitives to AI agents</title>
        <description>&lt;p&gt;AI agents are useful in proportion to the context they can read. That is the productive observation. The unproductive one, which follows about thirty seconds later, is that giving an agent the context it wants usually means giving it broad access to production data. Most teams reach for one of three approaches: hand the agent a database credential, filter the agent’s behavior at the prompt layer (“don’t query the customers table unless asked”), or trust the agent because the model is good now. The first reproduces every shared-credential failure of the last twenty years. The second is policy enforcement at the wrong layer, since it depends on the thing being constrained to cooperate with the constraint. The third is not a policy.&lt;/p&gt;

&lt;p&gt;The pattern that holds up is one identity engineers already recognize: scoped delegation through a narrow, audited, &lt;a href=&quot;https://csrc.nist.gov/projects/role-based-access-control&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;RBAC&lt;/a&gt;-governed capability surface. The &lt;a href=&quot;https://modelcontextprotocol.io&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Model Context Protocol&lt;/a&gt; (the emerging interface for exposing tools to LLM agents) provides a natural place to implement that pattern, and a &lt;a href=&quot;/connect-your-ai-to-postgres-with-mcp&quot; target=&quot;_blank&quot;&gt;read-only database MCP server&lt;/a&gt;, designed correctly, is an instance of it. This post is about the principles underneath it.&lt;/p&gt;

&lt;h2 id=&quot;introducing-scoped-delegation-pattern&quot;&gt;Introducing: scoped delegation pattern&lt;/h2&gt;

&lt;p&gt;In IAM terms, an AI agent is a non-human principal. That framing is doing more work than it looks. Principals need identities, capabilities they are authorized to invoke, and audit trails attached to each invocation. The model is not new; it is the model used for every service account, every workload identity, every cross-account role a backend service has held for the last decade. What changes with AI agents is the cardinality and the unpredictability: there are more of them, they are spun up casually, and their behavior is governed by a probabilistic policy nobody fully controls.&lt;/p&gt;

&lt;p&gt;An MCP server is a policy-enforcement point. The policy decision is encoded in the surface of the server: which tools exist, what each tool accepts as input, what each tool is permitted to return, and which principals are allowed to invoke which tools. A backend service calling a database through a narrow IAM role does not hold credentials for the database; it holds authorization to invoke a specific set of capabilities. The agent’s relationship to the MCP server is structurally identical. The agent never holds database credentials. It holds an identity and an authorization to call tools.&lt;/p&gt;

&lt;p&gt;That distinction is the entire point. If the agent held credentials, the surface of “what the agent can do” would be “anything the credentials grant.” With a capability surface in front of it, the surface of “what the agent can do” is the union of behaviors the tools permit. Those two surfaces look adjacent on a diagram. In production, they are an order of magnitude apart in blast radius.&lt;/p&gt;

&lt;p&gt;The MCP server, in this framing, is not a convenience layer for the agent. It is the policy-enforcement point in an authorization model that already exists. Building one well is the job of porting that authorization model into agent-shaped terms.&lt;/p&gt;

&lt;h2 id=&quot;why-rbac-is-the-right-starting-model&quot;&gt;Why RBAC is the right starting model&lt;/h2&gt;

&lt;p&gt;There is a temptation, when designing the authorization model for an agent, to reach immediately for &lt;a href=&quot;https://csrc.nist.gov/projects/attribute-based-access-control&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;attribute-based authorization&lt;/a&gt;. Each tool call gets a policy decision computed from request context, agent attributes, target object attributes, environmental conditions. The model is expressive, the model is the future, and the model is wrong for the first version.&lt;/p&gt;

&lt;p&gt;Start with RBAC for agents the same way you would for humans. Define a small set of agent roles, each describing a coherent job: &lt;code&gt;read_only_analyst&lt;/code&gt;, &lt;code&gt;customer_support_lookup&lt;/code&gt;, &lt;code&gt;engineering_debug&lt;/code&gt;. Bind each role to a fixed set of MCP tools. Bind each tool to a fixed set of database objects: which tables it touches, which columns it returns, which row-level predicates it always applies. The tool itself is a parameterized query, not a raw SQL interface, and the role grants the right to invoke it.&lt;/p&gt;

&lt;p&gt;That last constraint is load-bearing. A &lt;code&gt;run_sql(query)&lt;/code&gt; tool collapses the entire authorization model down to “agent can do anything the database role can do.” Every refinement above the database role becomes window dressing. The capability surface is supposed to be narrower than what the underlying credentials permit. A raw-query tool throws that away for flexibility the agent does not actually need.&lt;/p&gt;

&lt;p&gt;The shape of a well-designed tool, in pseudocode:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;tool &quot;lookup_customer_orders&quot;:
  binds_to_role: customer_support_lookup
  params:
    customer_id: uuid (required)
    limit: int (max=50, default=10)
  query: |
    SELECT order_id, status, created_at, total_cents
    FROM orders
    WHERE customer_id = :customer_id
      AND created_at &amp;gt;= now() - interval &apos;90 days&apos;
    ORDER BY created_at DESC
    LIMIT :limit
  rate_limit: 100/hour per principal
  audit: full
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;RBAC for agents pays for itself on the human side of the system. Reviews are tractable: the question reduces to “which tools is this role bound to, and is that set still appropriate.” Audits are tractable: every invocation maps back to a role, and every role maps back to a fixed surface. Onboarding a new agent reduces to picking a role. Incident response reduces to revoking one.&lt;/p&gt;

&lt;p&gt;ABAC and &lt;a href=&quot;https://research.google/pubs/pub48190/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;ReBAC&lt;/a&gt; are escape hatches for the 5% of cases RBAC genuinely cannot express: data sensitive because of its content rather than its location, relationships that bleed across object types, decisions that depend on request-time context. Reach for them when needed. Most teams reach too early, mistake expressive power for clarity, and end up with a policy graph nobody can answer questions about three months later.&lt;/p&gt;

&lt;h2 id=&quot;what-read-only-actually-has-to-mean&quot;&gt;What “read-only” actually has to mean&lt;/h2&gt;

&lt;p&gt;“Read-only” is a phrase that does not survive contact with a real authorization model. Most teams hear it and think “no INSERT, no UPDATE, no DELETE.” Necessary, not sufficient. Read-only at the IAM layer means a stricter set of things, all of which translate familiar IAM concerns into agent terms.&lt;/p&gt;

&lt;p&gt;No raw query interface. Every tool is a parameterized query with a fixed shape. The agent supplies values for the parameters, not the query itself. This is least privilege expressed at the capability level: the agent gets the queries it has been authorized for, not “the database, in read-only mode.”&lt;/p&gt;

&lt;p&gt;PII is redacted at the tool layer, not at the prompt layer. Prompt-layer redaction is policy enforcement at the wrong layer; it depends on the agent cooperating with the system that constrains it. Tool-layer redaction is enforced by the policy-enforcement point itself. The agent never sees what it is not entitled to see. This is data minimization translated from “do not log this field” to “do not return this field to this principal.”&lt;/p&gt;

&lt;p&gt;Output volume is bounded. An agent that can invoke a per-customer lookup ten thousand times can extract the customers table. Per-tool rate limits, per-principal rate limits, and result-set caps are not optimizations; they are policy. They prevent capability misuse by repetition.&lt;/p&gt;

&lt;p&gt;Cross-tool capability chaining is policed. If tool A returns a customer ID and tool B accepts a customer ID, the composition of A and B grants relationship access that neither tool grants alone. Static analysis of the tool surface catches the obvious cases. Runtime detection of suspicious call sequences catches the rest.&lt;/p&gt;

&lt;h2 id=&quot;audit-is-where-this-pattern-earns-its-keep&quot;&gt;Audit is where this pattern earns its keep&lt;/h2&gt;

&lt;p&gt;Audit is where the design earns most of its keep, and it is the part most early implementations underweight. Every tool invocation is a policy decision the system has already made, which means every invocation is a logged event with a fixed shape: which agent invoked the call, under which role, against which tool, with which parameters, returning which result fingerprint, at which timestamp.&lt;/p&gt;

&lt;p&gt;Two payoffs from getting this right.&lt;/p&gt;

&lt;p&gt;First, the audit log is the post-incident forensic record, the role audit logs have always played in human-IAM systems. When something has gone wrong, the question “what did this principal do, when, and against what” needs a deterministic answer. Reconstructing agent behavior from prompts and model outputs is hopeless. Reconstructing it from a list of tool calls is mechanical.&lt;/p&gt;

&lt;p&gt;Second, the audit log is the dataset on which agent behavior is tuned. Anomaly detection on agent activity becomes concrete once the data exists. A &lt;code&gt;customer_support_lookup&lt;/code&gt; role that suddenly starts paginating through every customer in the database is a signal whose detection does not require understanding the model; it requires understanding the policy. The same techniques that flagged a service account exfiltrating data in 2015 flag an agent doing the same thing now.&lt;/p&gt;

&lt;h2 id=&quot;what-this-pattern-does-not-solve&quot;&gt;What this pattern does not solve&lt;/h2&gt;

&lt;p&gt;Three honest limits, worth naming so they are not mistaken for places the pattern silently covers.&lt;/p&gt;

&lt;p&gt;Write paths. Agents that need to write are a different problem. Approval workflows, dry-run modes, two-person rules, staged commits: all of these are familiar from the write side of human-IAM systems and none of them are addressed by the read-only capability pattern. Read is the easier half of the problem. Write deserves its own design.&lt;/p&gt;

&lt;p&gt;Sensitive-by-context data. RBAC by tool and column does not capture “this record is sensitive because of who it is about.” A customer who is a minor, a transaction that is part of an active fraud investigation, an employee under HR review: none of these are detectable from the schema, and none of them are caught by the role-to-tool-to-column binding. This is genuinely ABAC territory, and a real reason to graduate from pure RBAC once the read pattern is solid.&lt;/p&gt;

&lt;p&gt;Capability chaining at scale. The surface of an MCP server with five tools is reviewable by inspection. The surface of one with fifty is not. The combinatorics of what an agent can infer from a sequence of legitimate calls become a real threat model, and the inferential reach can exceed the apparent permission of any single tool. Static analysis of the tool surface helps. Runtime detection of suspicious call sequences helps more. Neither makes the problem go away.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;AI agents are non-human principals at scale. The IAM community spent two decades figuring out how to give non-human principals safe access to systems. The teams that ignore that body of knowledge will rediscover its lessons the hard way, usually after an incident. The MCP read-only pattern is a way of porting one lesson, scoped delegation governed by RBAC, into the agentic era. The next ones worth porting, in roughly the order they are about to be needed, are just-in-time credentials, mutual authentication between agent and capability surface, and capability auditing across composed tool calls. Each is its own post.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://modelcontextprotocol.io&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Model Context Protocol specification&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://csrc.nist.gov/projects/role-based-access-control&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;NIST Role-Based Access Control (RBAC) project&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://csrc.nist.gov/projects/attribute-based-access-control&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;NIST Attribute-Based Access Control (ABAC) project&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://research.google/pubs/pub48190/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Zanzibar: Google’s Consistent, Global Authorization System&lt;/a&gt; - canonical modern reference for relationship-based access control (ReBAC)&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/connect-your-ai-to-postgres-with-mcp&quot; target=&quot;_blank&quot;&gt;Connecting PostgreSQL to AI tools via MCP&lt;/a&gt; - prior post covering MCP server setup mechanics&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/read-only-database-mcps-scoped-delegation-iam/</link>
        <guid isPermaLink="true">https://prateekcodes.com/read-only-database-mcps-scoped-delegation-iam/</guid>
        
        <category>mcp</category>
        
        <category>ai-agents</category>
        
        <category>iam</category>
        
        <category>rbac</category>
        
        <category>scoped-delegation</category>
        
        <category>authorization</category>
        
        <category>audit</category>
        
        <category>least-privilege</category>
        
        
        <category>AI</category>
        
        <category>MCP</category>
        
        <category>Security</category>
        
        <category>Identity</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 lets retry_on read the error when calculating wait time</title>
        <description>&lt;p&gt;Active Job’s &lt;code&gt;retry_on&lt;/code&gt; accepts a &lt;code&gt;wait:&lt;/code&gt; proc for custom backoff logic. Before Rails 8.2, that proc only received the execution count. When a remote API returns a &lt;code&gt;Retry-After&lt;/code&gt; header, there was no way to use that value inside the proc. Rails 8.2 fixes this by passing the exception as a second argument.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;wait:&lt;/code&gt; proc only knew how many times the job had been attempted:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class PaymentSyncJob &amp;lt; ApplicationJob
  retry_on Stripe::RateLimitError,
           attempts: 5,
           wait: -&amp;gt;(executions) { executions * 10 }

  def perform(order_id)
    Stripe::Charge.retrieve(order_id)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If Stripe responded with a &lt;code&gt;Retry-After: 30&lt;/code&gt; header, the job ignored it. The wait time was always based on the execution count, regardless of what the API actually asked for.&lt;/p&gt;

&lt;p&gt;To work around this, teams typically stored retry delay information on the exception class itself and then retrieved it through other means, which added boilerplate and coupling.&lt;/p&gt;

&lt;h2 id=&quot;rails-82&quot;&gt;Rails 8.2&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56601&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56601 allowing retry_on wait procs to accept the error (opens in new tab)&quot;&gt;PR #56601&lt;/a&gt; allows the &lt;code&gt;wait:&lt;/code&gt; proc to accept the exception as a second argument. Rails checks the proc’s arity, so existing one-argument procs continue to work without any changes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class PaymentSyncJob &amp;lt; ApplicationJob
  retry_on Stripe::RateLimitError,
           attempts: 5,
           wait: -&amp;gt;(executions, error) { error.retry_after || executions * 10 }

  def perform(order_id)
    Stripe::Charge.retrieve(order_id)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When the job retries, it calls the proc with both the execution count and the exception. If the error has a &lt;code&gt;retry_after&lt;/code&gt; value, that gets used. Otherwise, it falls back to the execution-based formula.&lt;/p&gt;

&lt;p&gt;This works for any error class that exposes delay information:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class ExternalApiJob &amp;lt; ApplicationJob
  retry_on ApiRateLimitError,
           attempts: 10,
           wait: -&amp;gt;(executions, error) do
             # Use the header value if available, cap at 5 minutes
             [error.retry_after || executions ** 2, 300].min
           end

  def perform(resource_id)
    ExternalApi.fetch(resource_id)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;backward-compatibility&quot;&gt;Backward Compatibility&lt;/h2&gt;

&lt;p&gt;The change is fully backward compatible. A proc with one argument behaves exactly as before:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Still works, receives only executions
retry_on SomeError, wait: -&amp;gt;(executions) { executions * 5 }

# New behavior, receives both
retry_on SomeError, wait: -&amp;gt;(executions, error) { error.retry_after || executions * 5 }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Rails uses Ruby’s &lt;code&gt;arity&lt;/code&gt; to determine which form the proc uses and calls it accordingly.&lt;/p&gt;

&lt;h2 id=&quot;when-to-use-this&quot;&gt;When to Use This&lt;/h2&gt;

&lt;p&gt;Use the two-argument form when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The API you call returns a &lt;code&gt;Retry-After&lt;/code&gt; header or equivalent&lt;/li&gt;
  &lt;li&gt;Your error class already captures the suggested wait time&lt;/li&gt;
  &lt;li&gt;You want backoff logic that adapts to what the remote service requests rather than using a fixed formula&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Rails 8.2 makes retry logic more accurate for jobs that talk to rate-limited APIs. By exposing the exception to the &lt;code&gt;wait:&lt;/code&gt; proc, jobs can respect what the remote service actually asks for instead of guessing.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56601&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56601 retry_on error-aware wait proc (opens in new tab)&quot;&gt;Pull Request #56601&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Active Job retry_on API documentation (opens in new tab)&quot;&gt;Active Job retry_on documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-82-retry-on-error-aware-wait/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-82-retry-on-error-aware-wait/</guid>
        
        <category>rails-8-2</category>
        
        <category>active-job</category>
        
        <category>retry</category>
        
        <category>background-jobs</category>
        
        <category>error-handling</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8.2</category>
        
        <category>Active Job</category>
        
      </item>
    
      <item>
        <title>Ruby::Box Practical Guide: Use Cases and Integration Patterns (Part 2)</title>
        <description>&lt;p&gt;In &lt;a href=&quot;/ruby-4-introduces-ruby-box-for-in-process-isolation-part-1/&quot;&gt;Part 1&lt;/a&gt;, we covered what &lt;code&gt;Ruby::Box&lt;/code&gt; is and how it provides namespace isolation. Now let’s explore practical patterns for integrating it into real applications.&lt;/p&gt;

&lt;h2 id=&quot;use-case-plugin-systems&quot;&gt;Use Case: Plugin Systems&lt;/h2&gt;

&lt;p&gt;Plugin systems benefit significantly from &lt;code&gt;Ruby::Box&lt;/code&gt;. Each plugin runs in its own isolated environment, preventing plugins from interfering with each other or the host application.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class PluginManager
  def initialize
    @plugins = {}
  end

  def load_plugin(name, path)
    box = Ruby::Box.new
    box.require(path)

    # Access the plugin class from within the box
    plugin_class = box.eval(&apos;Plugin&apos;)
    @plugins[name] = {
      box: box,
      instance: plugin_class.new
    }
  end

  def run(name, method, *args)
    plugin = @plugins[name]
    plugin[:instance].public_send(method, *args)
  end

  def unload(name)
    @plugins.delete(name)
    # Box becomes eligible for garbage collection
  end
end

# Usage
manager = PluginManager.new
manager.load_plugin(:markdown, &apos;./plugins/markdown_plugin&apos;)
manager.load_plugin(:syntax_highlight, &apos;./plugins/syntax_plugin&apos;)

# Each plugin has its own isolated environment
# If markdown_plugin patches String, syntax_plugin won&apos;t see it
manager.run(:markdown, :process, content)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This pattern ensures that a misbehaving plugin cannot corrupt the global namespace or break other plugins.&lt;/p&gt;

&lt;h2 id=&quot;use-case-multi-tenant-configuration&quot;&gt;Use Case: Multi-Tenant Configuration&lt;/h2&gt;

&lt;p&gt;Applications serving multiple tenants often need per-tenant configurations. &lt;code&gt;Ruby::Box&lt;/code&gt; provides clean isolation without complex scoping logic.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class TenantContext
  def initialize(tenant_id, config_path)
    @tenant_id = tenant_id
    @box = Ruby::Box.new
    @box.require(config_path)
  end

  def config
    @box.eval(&apos;TenantConfig&apos;)
  end

  def execute(code)
    @box.eval(code)
  end
end

# Each tenant gets isolated configuration
tenant_a = TenantContext.new(&apos;acme&apos;, &apos;./tenants/acme/config&apos;)
tenant_b = TenantContext.new(&apos;globex&apos;, &apos;./tenants/globex/config&apos;)

tenant_a.config.theme      # =&amp;gt; &quot;dark&quot;
tenant_b.config.theme      # =&amp;gt; &quot;light&quot;

# Global variables are isolated too
tenant_a.execute(&apos;$rate_limit = 100&apos;)
tenant_b.execute(&apos;$rate_limit = 500&apos;)

tenant_a.execute(&apos;$rate_limit&apos;)  # =&amp;gt; 100
tenant_b.execute(&apos;$rate_limit&apos;)  # =&amp;gt; 500
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;use-case-running-multiple-gem-versions&quot;&gt;Use Case: Running Multiple Gem Versions&lt;/h2&gt;

&lt;p&gt;During migrations, you might need to run two versions of the same gem simultaneously. &lt;code&gt;Ruby::Box&lt;/code&gt; makes this possible without separate processes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Load v1 API client in one box
v1_box = Ruby::Box.new
v1_box.eval &amp;lt;&amp;lt;~RUBY
  $LOAD_PATH.unshift(&apos;./vendor/api_client_v1/lib&apos;)
  require &apos;api_client&apos;
RUBY

# Load v2 API client in another box
v2_box = Ruby::Box.new
v2_box.eval &amp;lt;&amp;lt;~RUBY
  $LOAD_PATH.unshift(&apos;./vendor/api_client_v2/lib&apos;)
  require &apos;api_client&apos;
RUBY

# Compare behavior during migration
def compare_responses(endpoint, params)
  code = &quot;ApiClient.get(&apos;#{endpoint}&apos;, #{params.inspect})&quot;
  v1_response = v1_box.eval(code)
  v2_response = v2_box.eval(code)

  if v1_response != v2_response
    log_difference(endpoint, v1_response, v2_response)
  end

  v1_response  # Return v1 for now, switch to v2 when ready
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;use-case-isolated-monkey-patches-for-testing&quot;&gt;Use Case: Isolated Monkey Patches for Testing&lt;/h2&gt;

&lt;p&gt;Some tests require monkey patches that would pollute the global namespace. &lt;code&gt;Ruby::Box&lt;/code&gt; keeps these contained.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# test_helper.rb
def create_time_frozen_box(frozen_time)
  box = Ruby::Box.new
  box.eval &amp;lt;&amp;lt;~RUBY
    class Time
      def self.now
        Time.new(#{frozen_time.year}, #{frozen_time.month}, #{frozen_time.day})
      end
    end
  RUBY
  box
end

# In your test
def test_subscription_expiry
  box = create_time_frozen_box(Time.new(2026, 1, 1))

  # Load and test code within the frozen-time box
  box.eval &amp;lt;&amp;lt;~RUBY
    expiry_date = Time.new(2025, 12, 31)
    subscription = Subscription.new(expires_at: expiry_date)
    raise &quot;Expected expired&quot; unless subscription.expired?
  RUBY

  # Time.now is unchanged outside the box
  Time.now  # =&amp;gt; Current actual time
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;use-case-shadow-testing&quot;&gt;Use Case: Shadow Testing&lt;/h2&gt;

&lt;p&gt;Run new code paths alongside production code to compare results without affecting users. This pattern is useful for validating refactors or new implementations.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class ShadowRunner
  def initialize(production_box, shadow_box)
    @production = production_box
    @shadow = shadow_box
  end

  def run(method, *args)
    code = &quot;#{method}(#{args.map(&amp;amp;:inspect).join(&apos;, &apos;)})&quot;

    # Production path returns the result
    production_result = @production.eval(code)

    # Shadow path runs asynchronously, logs differences
    Thread.new do
      shadow_result = @shadow.eval(code)

      unless production_result == shadow_result
        Logger.warn(&quot;Shadow mismatch for #{method}&quot;,
          production: production_result,
          shadow: shadow_result
        )
      end
    end

    production_result
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;working-around-native-extension-issues&quot;&gt;Working Around Native Extension Issues&lt;/h2&gt;

&lt;p&gt;Native extensions may fail to install with &lt;code&gt;RUBY_BOX=1&lt;/code&gt; enabled. The solution is to separate installation from execution:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Gemfile installation without Boxing
bundle install

# Application execution with Boxing
RUBY_BOX=1 bundle exec ruby app.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For CI/CD pipelines:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/test.yml
jobs:
  test:
    steps:
      - name: Install dependencies
        run: bundle install

      - name: Run tests with Ruby::Box
        run: RUBY_BOX=1 bundle exec rspec
        env:
          RUBY_BOX: &quot;1&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;working-around-activesupport-issues&quot;&gt;Working Around ActiveSupport Issues&lt;/h2&gt;

&lt;p&gt;Some ActiveSupport core extensions have compatibility issues. Load them in your main context before creating boxes:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# At application startup, before creating any boxes
require &apos;active_support/core_ext/string/inflections&apos;
require &apos;active_support/core_ext/hash/keys&apos;

# Now create boxes for isolated code
plugin_box = Ruby::Box.new
# Plugins can use the already-loaded extensions
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Alternatively, selectively load only what you need inside boxes:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval &amp;lt;&amp;lt;~RUBY
  # Load specific extensions that are known to work
  require &apos;active_support/core_ext/object/blank&apos;
RUBY
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;performance-considerations&quot;&gt;Performance Considerations&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; adds minimal overhead for most operations:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Method dispatch&lt;/strong&gt;: Slightly more indirection through separate method tables&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Object creation&lt;/strong&gt;: Unaffected, objects pass freely between boxes&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Memory&lt;/strong&gt;: Each box maintains its own class/module definitions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For performance-critical paths, cache class references:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class OptimizedPluginRunner
  def initialize(box)
    @box = box
    # Cache the class reference once
    @processor_class = box.eval(&apos;DataProcessor&apos;)
  end

  def process(data)
    # Use cached reference instead of evaluating each time
    @processor_class.new.process(data)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;when-to-use-rubybox&quot;&gt;When to Use &lt;code&gt;Ruby::Box&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good candidates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Plugin or extension systems where isolation is critical&lt;/li&gt;
  &lt;li&gt;Multi-tenant applications with per-tenant customizations&lt;/li&gt;
  &lt;li&gt;Testing scenarios requiring invasive monkey patches&lt;/li&gt;
  &lt;li&gt;Gradual migration between gem versions&lt;/li&gt;
  &lt;li&gt;Applications loading third-party code that might conflict&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Poor candidates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Running untrusted or potentially malicious code (use OS-level sandboxing)&lt;/li&gt;
  &lt;li&gt;Production systems until the feature stabilizes&lt;/li&gt;
  &lt;li&gt;Applications heavily dependent on native extensions&lt;/li&gt;
  &lt;li&gt;Simple applications without isolation requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;migration-strategy&quot;&gt;Migration Strategy&lt;/h2&gt;

&lt;p&gt;If you’re considering &lt;code&gt;Ruby::Box&lt;/code&gt; for an existing application:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Test compatibility&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Run your test suite with Boxing enabled
RUBY_BOX=1 bundle exec rspec
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Identify issues&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look for failures related to:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Shared global state across files&lt;/li&gt;
  &lt;li&gt;Assumptions about class modifications being visible everywhere&lt;/li&gt;
  &lt;li&gt;Native extension loading errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Refactor incrementally&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start with isolated subsystems that don’t share state with the rest of your application. Move more code into boxes as you gain confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Monitor in staging&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run your staging environment with &lt;code&gt;RUBY_BOX=1&lt;/code&gt; before considering production deployment.&lt;/p&gt;

&lt;h2 id=&quot;whats-next-for-rubybox&quot;&gt;What’s Next for &lt;code&gt;Ruby::Box&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;The Ruby core team has discussed building a higher-level “packages” API on top of &lt;code&gt;Ruby::Box&lt;/code&gt;. This would provide more ergonomic ways to manage gem isolation without manual box management. Track progress in &lt;a href=&quot;https://bugs.ruby-lang.org/issues/21681&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby packages feature discussion (opens in new tab)&quot;&gt;Ruby Issue #21681&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; solves real problems around namespace pollution and gem conflicts. While still experimental, it’s worth exploring for applications where isolation matters. Start with non-critical paths, understand the limitations, and provide feedback to the Ruby core team as you experiment.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.ruby-lang.org/en/master/Ruby/Box.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby::Box official documentation (opens in new tab)&quot;&gt;Ruby::Box Official Documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/geeknees/ruby_box_shadow_universe&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby::Box shadow execution example repository (opens in new tab)&quot;&gt;Ruby::Box Shadow Execution Example&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rubykaigi.org/2025/presentations/tagomoris.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;State of Namespace presentation at RubyKaigi 2025 (opens in new tab)&quot;&gt;RubyKaigi 2025: State of Namespace&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://bugs.ruby-lang.org/issues/21681&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby packages feature discussion (opens in new tab)&quot;&gt;Ruby Issue #21681: Packages API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 15 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/ruby-4-ruby-box-practical-guide-part-2/</link>
        <guid isPermaLink="true">https://prateekcodes.com/ruby-4-ruby-box-practical-guide-part-2/</guid>
        
        <category>ruby</category>
        
        <category>ruby-box</category>
        
        <category>namespace</category>
        
        <category>isolation</category>
        
        <category>plugins</category>
        
        <category>multi-tenant</category>
        
        <category>ruby-4</category>
        
        
        <category>Ruby</category>
        
        <category>Ruby 4.0</category>
        
        <category>Isolation</category>
        
      </item>
    
      <item>
        <title>Ruby 4.0 Introduces Ruby::Box for In-Process Isolation (Part 1)</title>
        <description>&lt;p&gt;Ruby 4.0 introduces &lt;code&gt;Ruby::Box&lt;/code&gt;, a feature that provides isolated namespaces within a single Ruby process. This solves a long-standing problem: monkey patches and global modifications from one gem affecting all other code in your application.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-with-shared-namespaces&quot;&gt;The Problem with Shared Namespaces&lt;/h2&gt;

&lt;p&gt;When you load a gem that modifies core classes, those changes affect everything in your Ruby process:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Gem A adds a titleize method to String
class String
  def titleize
    split.map(&amp;amp;:capitalize).join(&apos; &apos;)
  end
end

# Now EVERY piece of code in your process sees this method
# Including Gem B, which might have its own expectations

&quot;hello world&quot;.titleize  # =&amp;gt; &quot;Hello World&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This becomes problematic when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Two gems define conflicting methods on the same class&lt;/li&gt;
  &lt;li&gt;A gem’s monkey patch breaks another library’s assumptions&lt;/li&gt;
  &lt;li&gt;You want to test code in isolation from invasive patches&lt;/li&gt;
  &lt;li&gt;You need to run multiple versions of a gem simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before Ruby 4.0, the only solutions were separate Ruby processes (with IPC overhead) or containers (with even more overhead).&lt;/p&gt;

&lt;h2 id=&quot;ruby-40-enter-rubybox&quot;&gt;Ruby 4.0: Enter Ruby::Box&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; creates isolated spaces where code runs with its own class definitions, constants, and global variables. Changes made inside a box stay inside that box.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Enable with environment variable at startup
# RUBY_BOX=1 ruby my_script.rb

# Check if Boxing is available
Ruby::Box.enabled?  # =&amp;gt; true

# Create an isolated box
box = Ruby::Box.new

# Load code that patches String
box.eval &amp;lt;&amp;lt;~RUBY
  class String
    def shout
      upcase + &quot;!!!&quot;
    end
  end
RUBY

# The patch exists only inside the box
box.eval(&apos;&quot;hello&quot;.shout&apos;)  # =&amp;gt; &quot;HELLO!!!&quot;

# Outside the box, String is unchanged
&quot;hello&quot;.shout  # =&amp;gt; NoMethodError: undefined method `shout&apos;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;understanding-box-types&quot;&gt;Understanding Box Types&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; operates with three types of boxes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root Box&lt;/strong&gt;: Contains all built-in Ruby classes and modules. This is established before any user code runs and serves as the template for other boxes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main Box&lt;/strong&gt;: Your application’s default execution context. It’s automatically created from the root box when the process starts. This is where your main script runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User Boxes&lt;/strong&gt;: Custom boxes you create with &lt;code&gt;Ruby::Box.new&lt;/code&gt;. Each is copied from the root box, giving it a clean slate of built-in classes without any modifications from the main box or other user boxes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Your script runs in the &quot;main&quot; box
Ruby::Box.current  # =&amp;gt; #&amp;lt;Ruby::Box main&amp;gt;

# Create isolated boxes
plugin_box = Ruby::Box.new
another_box = Ruby::Box.new

# Each box is independent
plugin_box.object_id != another_box.object_id  # =&amp;gt; true
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;the-rubybox-api&quot;&gt;The Ruby::Box API&lt;/h2&gt;

&lt;p&gt;The API is straightforward with just a few methods:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Creation
box = Ruby::Box.new

# Loading code
box.require(&apos;some_library&apos;)        # Respects box&apos;s $LOAD_PATH
box.require_relative(&apos;./my_file&apos;)  # Relative to current file
box.load(&apos;script.rb&apos;)              # Direct file execution

# Executing code
box.eval(&apos;1 + 1&apos;)                  # Execute Ruby code as string

# Inspection
Ruby::Box.current    # Returns the currently executing box
Ruby::Box.enabled?   # Check if Boxing is active
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;what-gets-isolated&quot;&gt;What Gets Isolated&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; isolates several aspects of the Ruby runtime:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classes and Constants&lt;/strong&gt;: Reopening a built-in class in one box doesn’t affect other boxes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval &amp;lt;&amp;lt;~RUBY
  class Array
    def sum_squares
      map { |n| n ** 2 }.sum
    end
  end
RUBY

box.eval(&apos;[1, 2, 3].sum_squares&apos;)  # =&amp;gt; 14
[1, 2, 3].sum_squares              # =&amp;gt; NoMethodError
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Global Variables&lt;/strong&gt;: Changes to globals stay within the box.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval(&apos;$my_config = { debug: true }&apos;)

box.eval(&apos;$my_config&apos;)  # =&amp;gt; { debug: true }
$my_config              # =&amp;gt; nil
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Top-Level Methods&lt;/strong&gt;: Methods defined at the top level become private instance methods of &lt;code&gt;Object&lt;/code&gt; within that box only.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval &amp;lt;&amp;lt;~RUBY
  def helper_method
    &quot;I&apos;m only available in this box&quot;
  end
RUBY

box.eval(&apos;helper_method&apos;)  # =&amp;gt; &quot;I&apos;m only available in this box&quot;
helper_method              # =&amp;gt; NoMethodError
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;enabling-rubybox&quot;&gt;Enabling Ruby::Box&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; is disabled by default. Enable it by setting the &lt;code&gt;RUBY_BOX&lt;/code&gt; environment variable before the Ruby process starts:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;RUBY_BOX=1 ruby my_application.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: Setting &lt;code&gt;RUBY_BOX&lt;/code&gt; after the process has started has no effect. The boxing infrastructure must be initialized during Ruby’s boot sequence, so the variable must be set before the Ruby process starts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# This check should be at the top of your application
unless Ruby::Box.enabled?
  warn &quot;Ruby::Box is not enabled. Start with RUBY_BOX=1&quot;
  exit 1
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;important-limitations&quot;&gt;Important Limitations&lt;/h2&gt;

&lt;p&gt;Before adopting &lt;code&gt;Ruby::Box&lt;/code&gt;, be aware of these constraints:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not a Security Sandbox&lt;/strong&gt;: &lt;code&gt;Ruby::Box&lt;/code&gt; provides namespace isolation, not security isolation. Code in a box can still access the filesystem, network, and system resources. Do not use it to run untrusted code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native Extensions&lt;/strong&gt;: Installing gems with native extensions may fail when &lt;code&gt;RUBY_BOX=1&lt;/code&gt; is set. The workaround is to install gems without the flag, then run your application with it enabled.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install gems normally
bundle install

# Run with Boxing enabled
RUBY_BOX=1 bundle exec ruby app.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;ActiveSupport Compatibility&lt;/strong&gt;: Some parts of &lt;code&gt;active_support/core_ext&lt;/code&gt; have compatibility issues with &lt;code&gt;Ruby::Box&lt;/code&gt;. Load &lt;code&gt;ActiveSupport&lt;/code&gt; in your main context before creating boxes if needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experimental Status&lt;/strong&gt;: This feature is experimental in Ruby 4.0. Behavior may change in future versions. The Ruby core team recommends experimentation but advises caution in production environments.&lt;/p&gt;

&lt;h2 id=&quot;file-scope-execution&quot;&gt;File Scope Execution&lt;/h2&gt;

&lt;p&gt;One important detail: &lt;code&gt;Ruby::Box&lt;/code&gt; operates on a file-scope basis. Each &lt;code&gt;.rb&lt;/code&gt; file executes entirely within a single box. Once loaded, all methods and procs defined in that file operate within their originating box, regardless of where they’re called from.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# helper.rb
def process(data)
  # This method always runs in the box where helper.rb was loaded
  data.transform
end

# main.rb
box = Ruby::Box.new
box.require_relative(&apos;helper&apos;)

# Even when called from main, process() runs in box&apos;s context
box.eval(&apos;process(my_data)&apos;)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; brings a long-requested capability to Ruby: proper namespace isolation without process boundaries. In &lt;a href=&quot;/ruby-4-ruby-box-practical-guide-part-2/&quot;&gt;Part 2&lt;/a&gt;, we’ll explore practical use cases including plugin systems, multi-tenant configurations, and strategies for gradual adoption.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.ruby-lang.org/en/master/Ruby/Box.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby::Box official documentation (opens in new tab)&quot;&gt;Ruby::Box Official Documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby 4.0.0 release announcement (opens in new tab)&quot;&gt;Ruby 4.0.0 Release Notes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://dev.to/ko1/rubybox-digest-introduction-ruby-400-new-feature-3bch&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;ko1&apos;s Ruby::Box introduction on DEV.to (opens in new tab)&quot;&gt;Ruby::Box Introduction by ko1&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rubyreferences.github.io/rubychanges/4.0.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby 4.0 changes reference (opens in new tab)&quot;&gt;Ruby 4.0 Changes Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 14 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/ruby-4-introduces-ruby-box-for-in-process-isolation-part-1/</link>
        <guid isPermaLink="true">https://prateekcodes.com/ruby-4-introduces-ruby-box-for-in-process-isolation-part-1/</guid>
        
        <category>ruby</category>
        
        <category>ruby-box</category>
        
        <category>namespace</category>
        
        <category>isolation</category>
        
        <category>monkey-patching</category>
        
        <category>ruby-4</category>
        
        
        <category>Ruby</category>
        
        <category>Ruby 4.0</category>
        
        <category>Isolation</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 makes enqueue_after_transaction_commit the default</title>
        <description>&lt;p&gt;&lt;a href=&quot;/rails-72-enqueue-after-transaction-commit&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Blog post about Rails 7.2 enqueue_after_transaction_commit (opens in new tab)&quot;&gt;Rails 7.2&lt;/a&gt; introduced &lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; to prevent race conditions when jobs are enqueued inside database transactions. However, it required explicit opt-in. Rails 8.2 flips the default. Jobs are now automatically deferred until after the transaction commits.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-with-opt-in&quot;&gt;The Problem with Opt-In&lt;/h2&gt;

&lt;p&gt;With the opt-in approach in Rails 7.2, teams had to remember to enable the feature:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;config.active_job.enqueue_after_transaction_commit = :default
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Or configure it per-job:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class WelcomeEmailJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :always
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This created inconsistency. Some jobs would be transaction-aware, others would not. The safer behavior required explicit action.&lt;/p&gt;

&lt;h2 id=&quot;rails-82-changes-the-default&quot;&gt;Rails 8.2 Changes the Default&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55788&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR making enqueue_after_transaction_commit the default (opens in new tab)&quot;&gt;PR #55788&lt;/a&gt; changes this. When you upgrade to Rails 8.2 and run &lt;code&gt;load_defaults &quot;8.2&quot;&lt;/code&gt;, jobs are automatically deferred until after the transaction commits.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;def create
  User.transaction do
    user = User.create!(params)
    WelcomeEmailJob.perform_later(user)  # Deferred until commit
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No configuration needed. The job waits for the transaction to complete before being dispatched to the queue.&lt;/p&gt;

&lt;h2 id=&quot;opting-out&quot;&gt;Opting Out&lt;/h2&gt;

&lt;p&gt;If you need immediate enqueueing for backward compatibility or specific use cases, you have two options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;config.active_job.enqueue_after_transaction_commit = false
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Per-job configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class TimeStampedJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = false
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;why-the-global-config-was-restored&quot;&gt;Why the Global Config Was Restored&lt;/h2&gt;

&lt;p&gt;The global configuration option has an interesting history. It was deprecated and removed in Rails 8.1. The team initially wanted each job to declare its own preference. However, changing the default behavior without a global opt-out would break existing applications.&lt;/p&gt;

&lt;p&gt;The PR restored the global configuration specifically to allow apps upgrading to Rails 8.2 to maintain their existing behavior without modifying every job class.&lt;/p&gt;

&lt;h2 id=&quot;when-this-matters&quot;&gt;When This Matters&lt;/h2&gt;

&lt;p&gt;The new default primarily affects jobs enqueued to external queues like Redis (Sidekiq, Resque). If you use a database-backed queue like Solid Queue or GoodJob with the same database, your jobs are already part of the same transaction.&lt;/p&gt;

&lt;p&gt;Jobs that do not depend on transaction data can still be configured for immediate enqueueing if needed.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Rails 8.2 makes the safer behavior the default. Jobs enqueued inside transactions automatically wait for the commit, eliminating a common source of race conditions without requiring explicit configuration.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55788&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR making enqueue_after_transaction_commit the default (opens in new tab)&quot;&gt;Pull Request #55788&lt;/a&gt; making this the default&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/51426&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR introducing enqueue_after_transaction_commit (opens in new tab)&quot;&gt;Pull Request #51426&lt;/a&gt; introducing the feature in Rails 7.2&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/rails-72-enqueue-after-transaction-commit&quot; aria-label=&quot;Blog post about Rails 7.2 enqueue_after_transaction_commit&quot;&gt;Rails 7.2 enqueue_after_transaction_commit&lt;/a&gt; - detailed explanation of the feature&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 31 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-82-enqueue-after-transaction-commit-default/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-82-enqueue-after-transaction-commit-default/</guid>
        
        <category>rails-8-2</category>
        
        <category>active-job</category>
        
        <category>transactions</category>
        
        <category>background-jobs</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8.2</category>
        
        <category>Active Job</category>
        
      </item>
    
      <item>
        <title>Rails 7.2 adds enqueue_after_transaction_commit to prevent job race conditions</title>
        <description>&lt;p&gt;Scheduling background jobs inside database transactions is a common anti-pattern which is a source of several production bugs in Rails applications. The job can execute before the transaction commits, leading to &lt;code&gt;RecordNotFound&lt;/code&gt; or &lt;code&gt;ActiveJob::DeserializationError&lt;/code&gt; because the data it needs does not exist yet. Or worse, the job could run assuming the txn would commit, but it rolls back at a later stage. We don’t need that kind of optimism.&lt;/p&gt;

&lt;p&gt;Rails 7.2 addresses this with &lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt;, which automatically defers job enqueueing until the transaction completes.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;Consider a typical pattern where you create a user and send a welcome email:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UsersController &amp;lt; ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)
      WelcomeEmailJob.perform_later(@user)
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This code works fine in development where your job queue is slow and transactions commit quickly. In production, with a fast Redis-backed queue like Sidekiq and a busy database, the job can start executing before the transaction commits:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Timeline:
1. Transaction begins
2. User INSERT executes (not committed yet)
3. Job enqueued to Redis
4. Sidekiq picks up job immediately
5. Job tries to find User -&amp;gt; RecordNotFound!
6. Transaction commits (too late)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The same problem occurs with &lt;code&gt;after_create&lt;/code&gt; callbacks in models:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Project &amp;lt; ApplicationRecord
  after_create -&amp;gt; { NotifyParticipantsJob.perform_later(self) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;the-workaround&quot;&gt;The Workaround&lt;/h3&gt;

&lt;p&gt;The standard fix was to use &lt;code&gt;after_commit&lt;/code&gt; callbacks instead:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Project &amp;lt; ApplicationRecord
  after_create_commit -&amp;gt; { NotifyParticipantsJob.perform_later(self) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Or wrap job scheduling in explicit &lt;code&gt;after_commit&lt;/code&gt; blocks:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UsersController &amp;lt; ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)

      ActiveRecord::Base.connection.after_transaction_commit do
        WelcomeEmailJob.perform_later(@user)
      end
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This worked but had problems:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Easy to forget&lt;/strong&gt;: Using &lt;code&gt;after_create&lt;/code&gt; instead of &lt;code&gt;after_create_commit&lt;/code&gt; is a common mistake&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Scattered logic&lt;/strong&gt;: Job scheduling gets coupled to model callbacks instead of staying in controllers or service objects&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Verbose&lt;/strong&gt;: Wrapping every &lt;code&gt;perform_later&lt;/code&gt; call in &lt;code&gt;after_commit&lt;/code&gt; blocks adds boilerplate&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Testing friction&lt;/strong&gt;: Transaction callbacks behave differently in test environments using database cleaner with transactions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/Envek/after_commit_everywhere&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;after_commit_everywhere gem on GitHub (opens in new tab)&quot;&gt;after_commit_everywhere&lt;/a&gt; gem became popular specifically to address this problem. It lets you use &lt;code&gt;after_commit&lt;/code&gt; callbacks anywhere in your application, not just in ActiveRecord models:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UserRegistrationService
  include AfterCommitEverywhere

  def call(params)
    User.transaction do
      user = User.create!(params)

      after_commit do
        WelcomeEmailJob.perform_later(user)
      end
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The gem hooks into ActiveRecord’s transaction lifecycle and ensures callbacks only fire after the outermost transaction commits. It handled nested transactions correctly and became a go-to solution for service objects that needed transaction-safe job scheduling.&lt;/p&gt;

&lt;p&gt;Some teams built their own lightweight wrappers instead:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Custom AsyncRecord class that hooks into transaction callbacks
class AsyncRecord
  def initialize(&amp;amp;block)
    @callback = block
  end

  def has_transactional_callbacks?
    true
  end

  def committed!(*)
    @callback.call
  end

  def rolledback!(*)
    # Do nothing if transaction rolled back
  end
end

# Usage
User.transaction do
  user = User.create!(params)
  record = AsyncRecord.new { WelcomeEmailJob.perform_later(user) }
  user.class.connection.add_transaction_record(record)
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Both approaches worked, but required teams to remember to use them consistently.&lt;/p&gt;

&lt;h2 id=&quot;rails-72&quot;&gt;Rails 7.2&lt;/h2&gt;

&lt;p&gt;Rails 7.2 makes Active Job transaction-aware. Jobs are automatically deferred until the transaction commits, and dropped if it rolls back.&lt;/p&gt;

&lt;p&gt;Enable it globally in your application:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/application.rb
config.active_job.enqueue_after_transaction_commit = :default
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now the original code just works:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UsersController &amp;lt; ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)
      WelcomeEmailJob.perform_later(@user)  # Deferred until commit
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The job only gets enqueued after the transaction successfully commits. If the transaction rolls back, the job is never enqueued.&lt;/p&gt;

&lt;h3 id=&quot;configuration-options&quot;&gt;Configuration Options&lt;/h3&gt;

&lt;p&gt;You can control this behavior at three levels:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/application.rb
config.active_job.enqueue_after_transaction_commit = :default
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Per-job configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class WelcomeEmailJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :always
end

class AuditLogJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :never  # Queue immediately
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The available values are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;:default&lt;/code&gt; - Let the queue adapter decide the behavior&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;:always&lt;/code&gt; - Always defer until transaction commits&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;:never&lt;/code&gt; - Queue immediately (pre-7.2 behavior)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;checking-enqueue-status&quot;&gt;Checking Enqueue Status&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;perform_later&lt;/code&gt; returns immediately even when the job is deferred, you can check if it was actually enqueued:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;User.transaction do
  user = User.create!(user_params)
  job = WelcomeEmailJob.perform_later(user)

  # job.successfully_enqueued? returns false here (still deferred)
end

# After transaction commits, job.successfully_enqueued? returns true
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;model-callbacks-simplified&quot;&gt;Model Callbacks Simplified&lt;/h3&gt;

&lt;p&gt;You can now safely use &lt;code&gt;after_create&lt;/code&gt; for job scheduling without worrying about transaction timing:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Project &amp;lt; ApplicationRecord
  # This is now safe with enqueue_after_transaction_commit enabled
  after_create -&amp;gt; { NotifyParticipantsJob.perform_later(self) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The job automatically waits for any enclosing transaction to complete.&lt;/p&gt;

&lt;h2 id=&quot;when-to-disable&quot;&gt;When to Disable&lt;/h2&gt;

&lt;p&gt;Some scenarios require immediate enqueueing:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Database-backed queues&lt;/strong&gt;: If you use Solid Queue, GoodJob, or Delayed Job with the same database, jobs are part of the same transaction and this deferral is unnecessary&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fire-and-forget jobs&lt;/strong&gt;: Jobs that do not depend on the transaction data can run immediately&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Time-sensitive operations&lt;/strong&gt;: If you need the job queued at a specific moment regardless of transaction state&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class TimeStampedJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :never

  def perform
    # This job needs to capture the exact enqueue time
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;update-rails-82-makes-this-the-default&quot;&gt;Update: Rails 8.2 Makes This the Default&lt;/h2&gt;

&lt;p&gt;Rails 8.2 makes &lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; the default behavior. Jobs are now automatically deferred until after the transaction commits without requiring explicit configuration.&lt;/p&gt;

&lt;p&gt;See &lt;a href=&quot;/rails-82-enqueue-after-transaction-commit-default&quot; aria-label=&quot;Blog post about Rails 8.2 enqueue_after_transaction_commit default&quot;&gt;Rails 8.2 makes enqueue_after_transaction_commit the default&lt;/a&gt; for details on the change, opting out, and the deprecation history.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; eliminates a common source of race conditions in Rails applications. Instead of remembering to use &lt;code&gt;after_commit&lt;/code&gt; callbacks or building custom workarounds, jobs are automatically deferred until transactions complete.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/51426&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR introducing enqueue_after_transaction_commit (opens in new tab)&quot;&gt;Pull Request #51426&lt;/a&gt; introducing the feature&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55788&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR making enqueue_after_transaction_commit the default in Rails 8.2 (opens in new tab)&quot;&gt;Pull Request #55788&lt;/a&gt; making this the default in Rails 8.2&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/issues/26045&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;DHH&apos;s original issue about job scheduling in transactions (opens in new tab)&quot;&gt;Original Issue #26045&lt;/a&gt; by DHH describing the problem&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://guides.rubyonrails.org/active_job_basics.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails Active Job documentation (opens in new tab)&quot;&gt;Active Job Basics Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Tue, 30 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-72-enqueue-after-transaction-commit/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-72-enqueue-after-transaction-commit/</guid>
        
        <category>rails-7-2</category>
        
        <category>active-job</category>
        
        <category>transactions</category>
        
        <category>background-jobs</category>
        
        <category>sidekiq</category>
        
        
        <category>Rails</category>
        
        <category>Rails 7.2</category>
        
        <category>Active Job</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 introduces Rails.app.creds for unified credential management</title>
        <description>&lt;p&gt;Applications often store secrets in both environment variables and encrypted credential files. Migrating between these storage methods or using both simultaneously has traditionally required code changes. Rails 8.2 solves this with &lt;code&gt;Rails.app.creds&lt;/code&gt;, a unified API that checks ENV first, then falls back to encrypted credentials.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;Managing credentials from multiple sources meant mixing different APIs:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class StripeService
  def initialize
    # Check ENV first, fallback to credentials
    @api_key = ENV[&quot;STRIPE_API_KEY&quot;] || Rails.application.credentials.dig(:stripe, :api_key)
    @webhook_secret = ENV.fetch(&quot;STRIPE_WEBHOOK_SECRET&quot;) {
      Rails.application.credentials.stripe&amp;amp;.webhook_secret
    }

    raise &quot;Missing Stripe API key!&quot; unless @api_key
  end
end

class DatabaseConfig
  def connection_url
    # Different syntax for each source
    ENV[&quot;DATABASE_URL&quot;] || Rails.application.credentials.database_url
  end

  def redis_url
    ENV.fetch(&quot;REDIS_URL&quot;, Rails.application.credentials.dig(:redis, :url) || &quot;redis://localhost:6379&quot;)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This approach has several problems:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Inconsistent APIs between &lt;code&gt;ENV.fetch()&lt;/code&gt; and &lt;code&gt;credentials.dig()&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Manual fallback logic scattered throughout the codebase&lt;/li&gt;
  &lt;li&gt;Code changes required when moving secrets between storage methods&lt;/li&gt;
  &lt;li&gt;Easy to forget nil checks on nested credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;rails-82&quot;&gt;Rails 8.2&lt;/h2&gt;

&lt;p&gt;The new &lt;code&gt;Rails.app.creds&lt;/code&gt; provides a consistent interface:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class StripeService
  def initialize
    @api_key = Rails.app.creds.require(:stripe_api_key)
    @webhook_secret = Rails.app.creds.require(:stripe_webhook_secret)
  end
end

class DatabaseConfig
  def connection_url
    Rails.app.creds.require(:database_url)
  end

  def redis_url
    Rails.app.creds.option(:redis_url, default: &quot;redis://localhost:6379&quot;)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;require&lt;/code&gt; method mandates a value exists and raises &lt;code&gt;KeyError&lt;/code&gt; if missing from both ENV and encrypted credentials. The &lt;code&gt;option&lt;/code&gt; method returns &lt;code&gt;nil&lt;/code&gt; or a default value gracefully.&lt;/p&gt;

&lt;h2 id=&quot;nested-keys&quot;&gt;Nested Keys&lt;/h2&gt;

&lt;p&gt;For nested credentials, pass multiple keys. Rails automatically converts them to the appropriate format for each source:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Checks ENV[&quot;AWS__ACCESS_KEY_ID&quot;] first, then credentials.dig(:aws, :access_key_id)
Rails.app.creds.require(:aws, :access_key_id)

# Multi-level nesting
# ENV[&quot;REDIS__CACHE__TTL&quot;] || credentials.dig(:redis, :cache, :ttl)
Rails.app.creds.option(:redis, :cache, :ttl, default: 3600)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The ENV lookup uses double underscores (&lt;code&gt;__&lt;/code&gt;) as separators for nested keys:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;:database_url&lt;/code&gt; → &lt;code&gt;ENV[&quot;DATABASE_URL&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;[:aws, :region]&lt;/code&gt; → &lt;code&gt;ENV[&quot;AWS__REGION&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;[:redis, :cache, :ttl]&lt;/code&gt; → &lt;code&gt;ENV[&quot;REDIS__CACHE__TTL&quot;]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;dynamic-defaults&quot;&gt;Dynamic Defaults&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;option&lt;/code&gt; method accepts callable defaults, evaluated only when needed:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;Rails.app.creds.option(:cache_ttl, default: -&amp;gt; { 1.hour })
Rails.app.creds.option(:max_connections, default: -&amp;gt; { calculate_pool_size })
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;env-only-access&quot;&gt;ENV-Only Access&lt;/h2&gt;

&lt;p&gt;Access environment variables directly using the same API via &lt;code&gt;Rails.app.envs&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Only checks ENV, no encrypted credentials fallback
Rails.app.envs.require(:port)
Rails.app.envs.option(:log_level, default: &quot;info&quot;)
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;custom-credential-sources&quot;&gt;Custom Credential Sources&lt;/h2&gt;

&lt;p&gt;Under the hood, &lt;code&gt;Rails.app.creds&lt;/code&gt; is powered by &lt;code&gt;ActiveSupport::CombinedConfiguration&lt;/code&gt;, which checks multiple credential sources (called backends) in order. By default, it checks ENV first, then encrypted credentials. You can customize this chain to include external secret managers:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  Rails.app.envs,                   # Check ENV first
  VaultConfiguration.new,           # Then HashiCorp Vault
  OnePasswordConfiguration.new,     # Then 1Password
  Rails.app.credentials             # Finally, encrypted credentials
)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each credential source needs to implement &lt;code&gt;require&lt;/code&gt; and &lt;code&gt;option&lt;/code&gt; methods matching the API.&lt;/p&gt;

&lt;h2 id=&quot;railsapp-alias&quot;&gt;Rails.app Alias&lt;/h2&gt;

&lt;p&gt;This feature comes alongside a new &lt;code&gt;Rails.app&lt;/code&gt; alias for &lt;code&gt;Rails.application&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Before
Rails.application.credentials.aws.access_key_id

# After
Rails.app.credentials.aws.access_key_id
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The shorter alias makes chained method calls more pleasant to read and write.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Rails.app.creds&lt;/code&gt; eliminates the friction of managing credentials across multiple sources. Secrets can move between ENV and encrypted files without touching application code.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56404&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56404 add Rails.app.creds (opens in new tab)&quot;&gt;PR #56404&lt;/a&gt; - Add Rails.app.creds for combined credentials lookup&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56403&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56403 add Rails.app alias (opens in new tab)&quot;&gt;PR #56403&lt;/a&gt; - Add Rails.app alias for Rails.application&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Mon, 29 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-8-2-combined-credentials-rails-app-creds/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-8-2-combined-credentials-rails-app-creds/</guid>
        
        <category>rails-8</category>
        
        <category>credentials</category>
        
        <category>configuration</category>
        
        <category>environment-variables</category>
        
        <category>secrets-management</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8</category>
        
        <category>Configuration</category>
        
      </item>
    
  </channel>
</rss>
