<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vignesh Athiappan</title>
    <description>The latest articles on DEV Community by Vignesh Athiappan (@vicky_acedia).</description>
    <link>https://dev.to/vicky_acedia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4028899%2F1e712c2d-9953-4fd8-855f-a0bdd45cb21b.jpeg</url>
      <title>DEV Community: Vignesh Athiappan</title>
      <link>https://dev.to/vicky_acedia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vicky_acedia"/>
    <language>en</language>
    <item>
      <title>PDF Rendering for HR at Scale: Why Your Simple Test Fails (and How to Fix It)</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Mon, 21 Sep 2026 03:42:35 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/pdf-rendering-for-hr-at-scale-why-your-simple-test-fails-and-how-to-fix-it-296n</link>
      <guid>https://dev.to/vicky_acedia/pdf-rendering-for-hr-at-scale-why-your-simple-test-fails-and-how-to-fix-it-296n</guid>
      <description>&lt;h1&gt;
  
  
  PDF Rendering for HR at Scale: Why Your Simple Test Fails (and How to Fix It)
&lt;/h1&gt;

&lt;p&gt;We spent two weeks evaluating PDF-generation vendors for our talent acquisition platform. Here's what we learned, why a naive test gives you garbage results, and the template anyone can use to pick the right engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Our platform generates offers, appointment letters, and onboarding docs for candidates across India, Mexico, and the US. These PDFs carry salary and personal data. We needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fidelity.&lt;/strong&gt; Multi-page documents with headers, footers, page numbers, table headers that repeat across pages, and no orphaned headings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; Data stays encrypted; no vendor logging of content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; We're bootstrapped; $10k/yr on a renderer we could build ourselves is hard to justify.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We looked at &lt;strong&gt;Gotenberg&lt;/strong&gt; (self-hosted), &lt;strong&gt;PDFShift&lt;/strong&gt;, and &lt;strong&gt;DocRaptor&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Broke Our Own Test
&lt;/h2&gt;

&lt;p&gt;Day one, we grabbed a screenshot of our profile screen from our HR system (Angular + PrimeNG), pasted the raw HTML into DocRaptor's try-it-out page, and got back a PDF full of grey boxes where icons should be.&lt;/p&gt;

&lt;p&gt;Conclusion: "PDF generation looks crap; all engines probably suck."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We were completely wrong.&lt;/strong&gt; We'd tested nothing.&lt;/p&gt;

&lt;p&gt;The screenshot HTML had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero inline CSS (all styles lived in component stylesheets on the page)&lt;/li&gt;
&lt;li&gt;Icon fonts and SVGs that never loaded (no external URLs allowed)&lt;/li&gt;
&lt;li&gt;Angular metadata (&lt;code&gt;_ngcontent-wij-c34&lt;/code&gt;) that meant nothing in a PDF&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;@page&lt;/code&gt; rules, no print CSS, no structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rendering a screen UI as a PDF is not the same as rendering a print template. Every engine — Prince, Chromium, even LibreOffice — gives the same result from garbage input: garbage output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Your test isn't testing the engine. It's testing your understanding of what a PDF template is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Right Test
&lt;/h2&gt;

&lt;p&gt;A real PDF is &lt;strong&gt;self-contained HTML with embedded CSS&lt;/strong&gt;. No external stylesheets. No dynamic JavaScript. No assumptions about available fonts.&lt;/p&gt;

&lt;p&gt;We built a single &lt;code&gt;.html&lt;/code&gt; file that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Contains everything.&lt;/strong&gt; Inline &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block with &lt;code&gt;@page&lt;/code&gt; rules, print CSS, margins, headers/footers, page numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uses print-specific CSS.&lt;/strong&gt; &lt;code&gt;@page { size: A4; margin: 28mm 20mm; }&lt;/code&gt;, &lt;code&gt;@top-center { content: element(pageHeader); }&lt;/code&gt;, &lt;code&gt;counter(page)&lt;/code&gt;, &lt;code&gt;page-break-inside: avoid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests the hard cases:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Multi-page table with &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; that repeats&lt;/li&gt;
&lt;li&gt;Rows that must never split across pages&lt;/li&gt;
&lt;li&gt;Running headers and footers&lt;/li&gt;
&lt;li&gt;Glyph rendering: ₹ (rupee), € (euro), ñ (Spanish n), and em-dashes&lt;/li&gt;
&lt;li&gt;Long numbered lists that span pages&lt;/li&gt;
&lt;li&gt;Signature blocks that shouldn't split&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uses dummy data, not real PII.&lt;/strong&gt; Template goes into version control; you can iterate on it without legal questions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28mm&lt;/span&gt; &lt;span class="m"&gt;20mm&lt;/span&gt; &lt;span class="m"&gt;24mm&lt;/span&gt; &lt;span class="m"&gt;20mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="err"&gt;@top-center&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageHeader&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;@bottom-center&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageFooter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.page-header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageHeader&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.page-footer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageFooter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border-collapse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;collapse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="nt"&gt;thead&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;table-header-group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* repeats on every page */&lt;/span&gt;
    &lt;span class="nt"&gt;tr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;page-break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* no row splits */&lt;/span&gt;
    &lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;page-break-after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* no orphaned headings */&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"page-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Header text&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"page-footer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Page &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"page-number"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Your content here --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@page&lt;/code&gt; and &lt;code&gt;page-break-*&lt;/code&gt; rules are where the magic happens. Most developers skip them because screen CSS doesn't need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Tested
&lt;/h2&gt;

&lt;p&gt;We sent the same template to three engines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Entry cost&lt;/th&gt;
&lt;th&gt;Ops burden&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DocRaptor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud API, Prince engine&lt;/td&gt;
&lt;td&gt;$15–75/mo&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDFShift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud API, Chromium&lt;/td&gt;
&lt;td&gt;$9/mo&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gotenberg&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-hosted Docker, Chromium + LibreOffice&lt;/td&gt;
&lt;td&gt;$0 license&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The scorecard:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test case&lt;/th&gt;
&lt;th&gt;DocRaptor (Prince)&lt;/th&gt;
&lt;th&gt;PDFShift (Chromium)&lt;/th&gt;
&lt;th&gt;Gotenberg (Chromium)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Headers / footers&lt;/td&gt;
&lt;td&gt;✓ Native CSS&lt;/td&gt;
&lt;td&gt;✗ API workaround needed&lt;/td&gt;
&lt;td&gt;✗ API workaround needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Page X of Y"&lt;/td&gt;
&lt;td&gt;✓ &lt;code&gt;counter(pages)&lt;/code&gt; works&lt;/td&gt;
&lt;td&gt;✗ Harder&lt;/td&gt;
&lt;td&gt;✗ Harder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running table headers&lt;/td&gt;
&lt;td&gt;✓ Perfect&lt;/td&gt;
&lt;td&gt;✓ Works&lt;/td&gt;
&lt;td&gt;✓ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row split prevention&lt;/td&gt;
&lt;td&gt;✓ Reliable&lt;/td&gt;
&lt;td&gt;✓ Works&lt;/td&gt;
&lt;td&gt;✓ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glyph rendering&lt;/td&gt;
&lt;td&gt;✓ All render&lt;/td&gt;
&lt;td&gt;✓ All render&lt;/td&gt;
&lt;td&gt;✓ All render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-line orphan control&lt;/td&gt;
&lt;td&gt;✓ Strong&lt;/td&gt;
&lt;td&gt;~ Moderate&lt;/td&gt;
&lt;td&gt;~ Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;250 KB&lt;/td&gt;
&lt;td&gt;280 KB&lt;/td&gt;
&lt;td&gt;270 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency (50 parallel)&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;p95 = 2.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Prince wins on CSS elegance (headers/footers as CSS, not API calls). Chromium ties on everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Decision
&lt;/h2&gt;

&lt;p&gt;If you have $75/mo budget and zero ops overhead matters, &lt;strong&gt;Prince (DocRaptor) wins slightly&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you have $0 tolerance for vendors, &lt;strong&gt;Gotenberg (free, self-hosted) is completely adequate&lt;/strong&gt;. The header/footer API calls are a one-time 20-line setup.&lt;/p&gt;

&lt;p&gt;If you're on a tight budget and already have a Kubernetes cluster, &lt;strong&gt;Gotenberg&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you want zero infrastructure and the budget is fine, &lt;strong&gt;DocRaptor&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We picked DocRaptor because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cost is fine at scale.&lt;/strong&gt; 200–300 offers/month = $29/mo tier. Not a blocker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero ops.&lt;/strong&gt; We don't want another container to upgrade and patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security is better than DIY.&lt;/strong&gt; Their SOC 2 audit, encryption in transit and at rest, 24-hour breach notification — better than we'd build ourselves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But we kept Gotenberg as a fallback. If DocRaptor ever blocked us or raised prices, swapping takes one day because we wrote the templates in standard CSS, no Prince extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Bits (Don't Forget These)
&lt;/h2&gt;

&lt;p&gt;PDF generation touches salary data. Before you pick a vendor:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Where does it run?&lt;/strong&gt; (DocRaptor: AWS us-east-1. That's fine for India if you have a contract and disclose it in your privacy notice.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is content logged?&lt;/strong&gt; (DocRaptor: no. Metadata only.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long is it stored?&lt;/strong&gt; (DocRaptor: configurable, encrypted in S3, auto-deleted.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's their security posture?&lt;/strong&gt; (DocRaptor: SOC 2 Type II, ISO 27001 path.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-processors?&lt;/strong&gt; (DocRaptor: AWS, Stripe, a few analytics tools. All listed.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ask these in an email before you commit. Don't assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Did Wrong (So You Don't)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tested the wrong input.&lt;/strong&gt; Screen HTML ≠ print template. We wasted a day learning that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Didn't specify print CSS.&lt;/strong&gt; &lt;code&gt;@page&lt;/code&gt; and &lt;code&gt;page-break-*&lt;/code&gt; aren't optional. They're the entire difference between "works" and "works perfectly."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assumed all engines were equivalent.&lt;/strong&gt; They're not. Prince is better at paged media; Chromium is fine and cheaper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Didn't think about fallback.&lt;/strong&gt; We picked the vendor, then realized we had no plan B. Now we keep the Gotenberg container ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Didn't test glyph rendering across locales.&lt;/strong&gt; We added that halfway through. It matters if you support multiple countries.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Template
&lt;/h2&gt;

&lt;p&gt;Here's the full test template we built. Use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;PDF Fidelity Test&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28mm&lt;/span&gt; &lt;span class="m"&gt;20mm&lt;/span&gt; &lt;span class="m"&gt;24mm&lt;/span&gt; &lt;span class="m"&gt;20mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="err"&gt;@top-center&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageHeader&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;@bottom-center&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageFooter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.page-header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageHeader&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#999&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.page-footer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageFooter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8.5pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#999&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Noto Sans"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;"DejaVu Sans"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Arial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10.5pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;6mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12.5pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8mm&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;3mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page-break-after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;3.5mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-collapse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;collapse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3mm&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;5mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="nt"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#bbb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;vertical-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;top&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="nt"&gt;th&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="nt"&gt;thead&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;table-header-group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="nt"&gt;tr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;page-break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.glyph-test&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.annexure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;page-break-before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"page-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;TEST TEMPLATE — Document&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"page-footer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Page &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pageNumber"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt; of &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"totalPages"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Document Title&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;This is a test template for PDF rendering evaluation.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Section 1: Basic content&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Lorem ipsum dolor sit amet, consectetur adipiscing elit.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Section 2: Glyph rendering&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;table&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"glyph-test"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Rupee (₹)&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;₹ 1,25,000&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Spanish (ñ, á, ¿)&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Señor, Ramírez, ¿Cuándo?&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Euro (€)&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;€ 1.234,56&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Typography&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;"Curly quotes" — em dash … ellipsis&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Section 3: Long table (tests header repeat and row integrity)&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;table&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;&lt;/span&gt;#&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;&lt;/span&gt;Item&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;&lt;/span&gt;Description&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;tbody&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row one&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;2&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row two&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;3&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row three&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;4&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row four&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;5&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row five&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;6&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row six&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;7&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row seven&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row eight&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;9&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row nine&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Row ten&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;This row should not split.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tbody&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"annexure"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Annexure A&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;This content starts on a new page.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Tables on this page should also have repeating headers.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;table&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;&lt;/span&gt;#&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;&lt;/span&gt;Item&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tbody&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Page 2 row 1&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;2&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Page 2 row 2&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/tbody&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save this, paste it into your vendor's try-it-out page or API, and score against the test cases above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the test first.&lt;/strong&gt; Don't assume all engines are the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the simplest thing that works.&lt;/strong&gt; If Chromium is 95% as good as Prince and costs 10x less, that's a win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build in fallback from day one.&lt;/strong&gt; Lock your templates to standard CSS. That's your insurance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask security questions early.&lt;/strong&gt; Not because vendors hide things, but because your legal team needs to know the answers before you go live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the decision.&lt;/strong&gt; Six months from now you won't remember why you picked this. Write it down.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;PDF rendering sounds simple until it's not. The engine matters less than the input. A good template — one that declares &lt;code&gt;@page&lt;/code&gt;, uses &lt;code&gt;page-break-*&lt;/code&gt;, and embeds fonts — works on any modern engine. A bad template fails everywhere.&lt;/p&gt;

&lt;p&gt;Test early, test with the right input, keep templates portable, and ask about security before you commit.&lt;/p&gt;

&lt;p&gt;That's it. Go render PDFs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We're using DocRaptor (for now). We haven't ruled out Gotenberg. If you've done this evaluation for your own platform, I'd love to hear what you picked and why.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systems</category>
    </item>
    <item>
      <title>Exposing a Microsoft Foundry Agent over A2A Through Azure API Management — The Click-by-Click Guide</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Sun, 06 Sep 2026 04:33:54 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/exposing-a-microsoft-foundry-agent-over-a2a-through-azure-api-management-the-click-by-click-guide-56ij</link>
      <guid>https://dev.to/vicky_acedia/exposing-a-microsoft-foundry-agent-over-a2a-through-azure-api-management-the-click-by-click-guide-56ij</guid>
      <description>&lt;p&gt;&lt;em&gt;Last verified: September 2026. Foundry's incoming-A2A endpoint is still in public preview; APIM's A2A agent API support is GA.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What you will have at the end
&lt;/h2&gt;

&lt;p&gt;A Foundry agent that any authorised client on your network can call &lt;strong&gt;through your API gateway&lt;/strong&gt;, using the open Agent2Agent (A2A) protocol, like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Postman / your app / another agent
        │
        │  POST  https://api.yourcompany.com/agents/helper-agent
        │  Header: Ocp-Apim-Subscription-Key
        ▼
Azure Front Door (optional, if your APIM is private)
        ▼
Azure API Management  ──►  gets its own Entra token  ──►  Microsoft Foundry
   (rate limits, keys,      (managed identity)             (your agent runs,
    logging, policies)                                       answers, done)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll finish when you send a JSON-RPC message from Postman and get the agent's answer back. Nothing more. Wiring a &lt;em&gt;second&lt;/em&gt; agent to call the first, or turning agents into MCP tools, are follow-on articles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why bother with the gateway at all?
&lt;/h3&gt;

&lt;p&gt;Foundry agents already have an endpoint. Two reasons to put APIM in front:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Governance.&lt;/strong&gt; Subscription keys, per-consumer quotas, content-safety policies, App Insights traces — all in one place you already operate. When Security asks "who can call this agent and how often?", the answer is an APIM product, not a wiki page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One front door for everything.&lt;/strong&gt; The same URL pattern works for Foundry agents today and for agents hosted anywhere else (Container Apps, other clouds) tomorrow.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Before you start — read this once
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Things you need&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A Foundry project (New Foundry experience toggled on)&lt;/td&gt;
&lt;td&gt;Where the agent lives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Owner&lt;/strong&gt; or &lt;strong&gt;Foundry Project Manager&lt;/strong&gt; on that project&lt;/td&gt;
&lt;td&gt;To create agents and assign roles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An APIM instance with &lt;strong&gt;System-assigned managed identity turned on&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;APIM must prove &lt;em&gt;who it is&lt;/em&gt; to Foundry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Contributor&lt;/strong&gt; on the APIM instance&lt;/td&gt;
&lt;td&gt;To add APIs and edit policies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postman (or curl)&lt;/td&gt;
&lt;td&gt;To test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~45 minutes&lt;/td&gt;
&lt;td&gt;Role assignments take a few minutes to propagate; budget for waiting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Placeholders used in this article — replace every one of them&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Where to find yours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contoso-agents-poc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Foundry &lt;strong&gt;account&lt;/strong&gt; name (the parent resource)&lt;/td&gt;
&lt;td&gt;Foundry portal → Manage → Project details → &lt;em&gt;Parent resource&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contoso-agents-poc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Foundry &lt;strong&gt;project&lt;/strong&gt; name&lt;/td&gt;
&lt;td&gt;Same page → &lt;em&gt;Name&lt;/em&gt;. Often identical to the account name for the default project.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;helper-agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The agent you're exposing&lt;/td&gt;
&lt;td&gt;Whatever you named it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contoso-apim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your APIM instance name&lt;/td&gt;
&lt;td&gt;Azure portal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://api.yourcompany.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The public URL clients use to reach APIM&lt;/td&gt;
&lt;td&gt;Either &lt;code&gt;https://contoso-apim.azure-api.net&lt;/code&gt; or your custom domain / Front Door hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The one URL that matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every Foundry agent has a stable endpoint. Its A2A path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://{account}.services.ai.azure.com/api/projects/{project}/agents/{agent}/endpoint/protocols/a2a
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filled in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://contoso-agents-poc.services.ai.azure.com/api/projects/contoso-agents-poc/agents/helper-agent/endpoint/protocols/a2a
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy that into a notepad. You'll paste it four times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two things that will confuse you if nobody warns you&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Foundry's A2A endpoint &lt;strong&gt;requires Entra authentication for everything&lt;/strong&gt;, including reading the agent card. Anything that tries to fetch it anonymously fails. APIM's import wizard does exactly that, so it &lt;em&gt;will&lt;/em&gt; fail, and that's expected. We handle it.&lt;/li&gt;
&lt;li&gt;Foundry serves A2A protocol &lt;strong&gt;v0.3 by default&lt;/strong&gt; unless you ask for v1.0 with a header. We'll set that header in APIM so clients don't have to think about it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part A — Set up the agent in Foundry
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A1. Create the agent (skip if you already have one)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://ai.azure.com" rel="noopener noreferrer"&gt;ai.azure.com&lt;/a&gt;. Make sure the &lt;strong&gt;New Foundry&lt;/strong&gt; toggle (top right) is &lt;strong&gt;on&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open your project → &lt;strong&gt;Build&lt;/strong&gt; → &lt;strong&gt;Agents&lt;/strong&gt; → &lt;strong&gt;+ Create agent&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;helper-agent&lt;/code&gt;. Pick a model (any deployed chat model — &lt;code&gt;gpt-4o-mini&lt;/code&gt; is fine).&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Instructions&lt;/strong&gt;, write something real. A blank agent answers like a generic chatbot, which makes later testing meaningless. Example:&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;You answer questions about company holiday policy. Be brief. If asked anything else, say it's outside your scope.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt; (top right). This creates version 1. The agent's endpoint is live from this moment — there is no separate "publish" step.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  A2. Create the agent card and enable A2A
&lt;/h3&gt;

&lt;p&gt;The &lt;em&gt;agent card&lt;/em&gt; is a small JSON document that tells other agents what this one can do. Creating it in the portal also turns on the A2A protocol for the agent.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;helper-agent&lt;/code&gt; → &lt;strong&gt;Details&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Find the &lt;strong&gt;A2A&lt;/strong&gt; / &lt;strong&gt;Agent card&lt;/strong&gt; section and click &lt;strong&gt;Create an agent card&lt;/strong&gt; (it's marked &lt;em&gt;Preview&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;Fill it in. Write it for a machine that has to decide whether to call you, not for a human:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;| Field | What to put | Example |&lt;br&gt;
   |---|---|---|&lt;br&gt;
   | Name | Pre-filled | &lt;code&gt;helper-agent&lt;/code&gt; |&lt;br&gt;
   | Description | 1–2 sentences: what it answers, what it doesn't | &lt;em&gt;Answers questions about company holiday and leave policy. Does not handle payroll or IT.&lt;/em&gt; |&lt;br&gt;
   | Skill name | Short noun phrase | &lt;code&gt;Holiday policy queries&lt;/code&gt; |&lt;br&gt;
   | Tags | Comma-separated keywords | &lt;code&gt;holiday, leave, policy, hr&lt;/code&gt; |&lt;br&gt;
   | Skill description | What a request looks like, what comes back | &lt;em&gt;Given a question about leave or holidays, returns the relevant policy answer.&lt;/em&gt; |&lt;br&gt;
   | Example prompts | 3–4 real ones, one per line | &lt;em&gt;How many days of annual leave do I get?&lt;/em&gt; |&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Save.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the Foundry side done. Your agent now accepts A2A requests at the URL you copied earlier — from anyone holding a valid Entra token with the right role. Next we make APIM that someone.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part B — Register the agent in API Management
&lt;/h2&gt;
&lt;h3&gt;
  
  
  B1. Open the A2A wizard
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Azure portal → your APIM instance (&lt;code&gt;contoso-apim&lt;/code&gt;) → left menu &lt;strong&gt;APIs&lt;/strong&gt; → &lt;strong&gt;+ Add API&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Find the tile &lt;strong&gt;A2A Agent&lt;/strong&gt;. Click it.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't see the tile?&lt;/strong&gt; A2A support arrived for v2 tiers first and was extended to classic tiers in the June 2026 release. Check your tier and that the instance is updated. If it's genuinely absent, stop here — the rest of this article won't apply.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  B2. Paste the agent card URL (and watch it fail on purpose)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;In &lt;strong&gt;URL&lt;/strong&gt;, paste the card URL — that's your A2A base URL with &lt;code&gt;/agentCard/v1.0&lt;/code&gt; on the end:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;   https://contoso-agents-poc.services.ai.azure.com/api/projects/contoso-agents-poc/agents/helper-agent/endpoint/protocols/a2a/agentCard/v1.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You'll see a red banner: &lt;em&gt;"We couldn't retrieve the agent card, possibly due to a wrong url or your network configuration. Enter the agent API details manually below."&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;This is expected.&lt;/strong&gt; APIM fetched the card anonymously; Foundry said no. The URL is not wrong. Carry on.&lt;/p&gt;
&lt;h3&gt;
  
  
  B3. Fill the form manually
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;JSON-RPC (pre-selected, greyed out — APIM only supports this one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime URL (JSON-RPC)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your A2A base URL — the one &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;/agentCard/v1.0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;helper-agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Helper Agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;helper-agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Products&lt;/td&gt;
&lt;td&gt;Leave empty for now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Base path&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agents/helper-agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As you type the base path, the two read-only URLs underneath update to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Base URL (JSON-RPC): &lt;code&gt;https://contoso-apim.azure-api.net/agents/helper-agent&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Agent card URL: &lt;code&gt;https://contoso-apim.azure-api.net/agents/helper-agent/agent-card.json&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click &lt;strong&gt;Create&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  B4. Don't panic about the blank field
&lt;/h3&gt;

&lt;p&gt;The Overview page shows &lt;em&gt;Backend runtime base URL&lt;/em&gt; as empty, even though you just filled Runtime URL. Sometimes the Settings page also shows it empty on reload. This is a portal quirk. We will set the backend explicitly in the policy in Part D, which makes this field irrelevant. Move on.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part C — Let APIM authenticate to Foundry
&lt;/h2&gt;

&lt;p&gt;Foundry only accepts requests carrying an Entra token from an identity that has a Foundry role on the project. APIM will present its own &lt;strong&gt;managed identity&lt;/strong&gt;. Two steps: confirm APIM has one, then grant it a role.&lt;/p&gt;
&lt;h3&gt;
  
  
  C1. Confirm APIM's managed identity is on
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Azure portal → the APIM &lt;strong&gt;service&lt;/strong&gt; (not the API you just made) → left menu &lt;strong&gt;Security&lt;/strong&gt; → &lt;strong&gt;Managed identities&lt;/strong&gt; → &lt;strong&gt;System assigned&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Status should be &lt;strong&gt;On&lt;/strong&gt;. Note the &lt;strong&gt;Object (principal) ID&lt;/strong&gt; — you'll recognise it in the next step.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it's &lt;strong&gt;Off&lt;/strong&gt;: switching it on is harmless technically, but on a shared or production instance tell whoever owns it first. Then toggle &lt;strong&gt;On&lt;/strong&gt; → &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  C2. Grant the managed identity a role on the Foundry project
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Azure portal → search for your Foundry &lt;strong&gt;project&lt;/strong&gt; resource (type: &lt;em&gt;Foundry project&lt;/em&gt;, not &lt;em&gt;Foundry account&lt;/em&gt;) → open it.&lt;/li&gt;
&lt;li&gt;Left menu &lt;strong&gt;Access control (IAM)&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt; → &lt;strong&gt;Add role assignment&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role&lt;/strong&gt; tab: search &lt;code&gt;Foundry User&lt;/code&gt;. Select it. &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Why Foundry User and not Foundry Agent Consumer?&lt;/em&gt; Agent Consumer is the least-privilege role designed for exactly this, but at the time of writing it doesn't appear in the portal's role picker. Foundry User is documented as "or higher" and works. If you need least-privilege, assign Agent Consumer via CLI with role definition ID &lt;code&gt;eed3b665-ab3a-47b6-8f48-c9382fb1dad6&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Members&lt;/strong&gt; tab: select &lt;strong&gt;Managed identity&lt;/strong&gt; → &lt;strong&gt;+ Select members&lt;/strong&gt;.

&lt;ul&gt;
&lt;li&gt;Subscription: the one your APIM lives in&lt;/li&gt;
&lt;li&gt;Managed identity dropdown: &lt;strong&gt;API Management service&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Pick &lt;code&gt;contoso-apim&lt;/code&gt; → &lt;strong&gt;Select&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + assign&lt;/strong&gt; → &lt;strong&gt;Review + assign&lt;/strong&gt; again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Back on the IAM page, expand &lt;strong&gt;Foundry User&lt;/strong&gt; and confirm &lt;code&gt;contoso-apim&lt;/code&gt; is listed with type &lt;em&gt;Managed identity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now wait 3–5 minutes.&lt;/strong&gt; Role assignments propagate asynchronously. If you test immediately you'll get 401/403 and think something's broken.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part D — The policy
&lt;/h2&gt;

&lt;p&gt;This is where APIM is told: &lt;em&gt;get a token as yourself, forward everything to the agent's A2A endpoint, and ask for protocol v1.0.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;APIM → &lt;strong&gt;APIs&lt;/strong&gt; → &lt;code&gt;helper-agent&lt;/code&gt; → left menu &lt;strong&gt;Policies&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You'll see an XML editor with &lt;code&gt;&amp;lt;policies&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;inbound&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;backend&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;outbound&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;on-error&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select all, delete, paste this&lt;/strong&gt;, replacing the URL with yours:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;policies&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;inbound&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;authentication-managed-identity&lt;/span&gt; &lt;span class="na"&gt;resource=&lt;/span&gt;&lt;span class="s"&gt;"https://ai.azure.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;set-backend-service&lt;/span&gt; &lt;span class="na"&gt;base-url=&lt;/span&gt;&lt;span class="s"&gt;"https://contoso-agents-poc.services.ai.azure.com/api/projects/contoso-agents-poc/agents/helper-agent/endpoint/protocols/a2a"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;set-header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"A2A-Version"&lt;/span&gt; &lt;span class="na"&gt;exists-action=&lt;/span&gt;&lt;span class="s"&gt;"override"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;value&amp;gt;&lt;/span&gt;1.0&lt;span class="nt"&gt;&amp;lt;/value&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/set-header&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/inbound&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;backend&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/backend&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;outbound&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/outbound&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;on-error&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/on-error&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/policies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Save.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Line by line, in plain words:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;authentication-managed-identity resource="https://ai.azure.com"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;APIM gets an Entra token for itself, scoped to Foundry, and puts it in the &lt;code&gt;Authorization&lt;/code&gt; header. This is the whole reason Part C existed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;set-backend-service base-url=…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Forward the request to the agent's A2A endpoint. Overrides whatever the Settings page did or didn't save.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;set-header A2A-Version 1.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ask Foundry for protocol v1.0. Without this you silently get v0.3, which has a different message shape.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;h2&gt;
  
  
  Part E — Get a subscription key
&lt;/h2&gt;

&lt;p&gt;By default an APIM API requires a subscription key. Grab one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;APIM → left menu &lt;strong&gt;Subscriptions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Find the row &lt;strong&gt;Built-in all-access subscription&lt;/strong&gt; (or create one scoped to this API).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;…&lt;/strong&gt; on the right → &lt;strong&gt;Show/hide keys&lt;/strong&gt; → copy &lt;strong&gt;Primary key&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep it in your notepad next to the URL.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part F — Test it
&lt;/h2&gt;
&lt;h3&gt;
  
  
  F1. Which URL to call
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;If your APIM is publicly reachable: &lt;code&gt;https://contoso-apim.azure-api.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If public access is disabled and you go through Front Door or a custom domain: that hostname instead, e.g. &lt;code&gt;https://api.yourcompany.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The path is the same either way: &lt;code&gt;/agents/helper-agent&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  F2. Set up the request in Postman
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;New request.&lt;/strong&gt; Method dropdown (left of the URL bar): &lt;strong&gt;POST&lt;/strong&gt;. Yes, POST. GET will return 404 from APIM and you'll waste ten minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;   https://api.yourcompany.com/agents/helper-agent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Headers&lt;/strong&gt; tab — two rows:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;| Key | Value |&lt;br&gt;
   |---|---|&lt;br&gt;
   | &lt;code&gt;Ocp-Apim-Subscription-Key&lt;/code&gt; | the key from Part E |&lt;br&gt;
   | &lt;code&gt;Content-Type&lt;/code&gt; | &lt;code&gt;application/json&lt;/code&gt; |&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Body&lt;/strong&gt; tab → &lt;strong&gt;raw&lt;/strong&gt; → set the dropdown on the right to &lt;strong&gt;JSON&lt;/strong&gt; → paste:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SendMessage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"messageId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ROLE_USER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"parts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello, what can you do?"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Send.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  F3. What success looks like
&lt;/h3&gt;

&lt;p&gt;Status &lt;strong&gt;200&lt;/strong&gt; and a body like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resp_…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"contextId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ctxt_…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TASK_STATE_COMPLETED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-06T04:18:09+00:00"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"artifacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"artifactId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"parts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I answer questions about company holiday policy. …"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent's reply is at &lt;code&gt;result.task.artifacts[0].parts[0].text&lt;/code&gt;. That path is what any consumer parses. &lt;code&gt;contextId&lt;/code&gt; is the conversation — send it back in later messages to keep context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're done.&lt;/strong&gt; Front Door → APIM → managed identity → Foundry → agent → back. Every hop authenticated, every call logged in APIM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting — read the status code, it's telling you exactly where it broke
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You see&lt;/th&gt;
&lt;th&gt;It means&lt;/th&gt;
&lt;th&gt;Do this&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;404&lt;/strong&gt; &lt;code&gt;{"statusCode":404,"message":"Resource not found"}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;APIM itself replied. Usually wrong HTTP method (GET instead of POST) or wrong path.&lt;/td&gt;
&lt;td&gt;Check the method dropdown. Check the base path matches Part B3.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;401&lt;/strong&gt; &lt;em&gt;"Access denied due to missing subscription key"&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;APIM replied. No key or wrong header name.&lt;/td&gt;
&lt;td&gt;Header must be exactly &lt;code&gt;Ocp-Apim-Subscription-Key&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;401 / 403&lt;/strong&gt; with headers like &lt;code&gt;azureml-served-by-cluster&lt;/code&gt; or a body mentioning authorization&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Foundry&lt;/strong&gt; replied — APIM got through, but Foundry rejected APIM's identity.&lt;/td&gt;
&lt;td&gt;Role hasn't propagated (wait 5 min) or Part C2 was assigned on the wrong resource (must be the &lt;em&gt;project&lt;/em&gt;). Confirm &lt;code&gt;authentication-managed-identity&lt;/code&gt; is in the policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;405&lt;/strong&gt; with &lt;code&gt;allow: POST&lt;/code&gt; and &lt;code&gt;azureml-served-by-cluster&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Foundry&lt;/strong&gt; replied. You sent a GET to the base path.&lt;/td&gt;
&lt;td&gt;This is actually good news: it proves the whole auth chain works. Switch to POST.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;200&lt;/strong&gt; but body has &lt;code&gt;"code": -32601&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Foundry replied: method not found. You're hitting v0.3 but sent a v1.0 body (or vice versa).&lt;/td&gt;
&lt;td&gt;Confirm the &lt;code&gt;A2A-Version&lt;/code&gt; header line is in the policy and saved.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;200&lt;/strong&gt; and the agent answers like a generic chatbot&lt;/td&gt;
&lt;td&gt;Everything works; your agent has no instructions.&lt;/td&gt;
&lt;td&gt;Part A1 step 4.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Front Door error page instead of any of the above&lt;/td&gt;
&lt;td&gt;Front Door has no route for &lt;code&gt;/agents/*&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Add a route rule or use the APIM hostname directly if reachable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Known gap: the agent card through APIM
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET https://api.yourcompany.com/agents/helper-agent/agent-card.json&lt;/code&gt; returns &lt;strong&gt;405&lt;/strong&gt; rather than the card.&lt;/p&gt;

&lt;p&gt;Because the import wizard couldn't fetch the card (Part B2), APIM never stored one, and it simply proxies the request to the agent's base path — which only accepts POST. Rewriting the path inside the policy (&lt;code&gt;rewrite-uri&lt;/code&gt;) didn't take effect on this API type in testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it matter?&lt;/strong&gt; Not for calling the agent — you just proved that. It matters for &lt;em&gt;discovery&lt;/em&gt;: a client that wants to read the card first (for example, another Foundry agent using "Connect via endpoint") will fail at that step. Two workarounds we haven't fully validated yet: serve the card from the policy using &lt;code&gt;send-request&lt;/code&gt; + &lt;code&gt;return-response&lt;/code&gt;, or fetch the card once with a user token and host it as a static file. Both are on the follow-up list.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Let another Foundry agent call this one.&lt;/strong&gt; In the calling agent → Tools → Add → A2A → &lt;em&gt;Connect via endpoint&lt;/em&gt;, URL = your APIM URL, auth = custom key &lt;code&gt;Ocp-Apim-Subscription-Key&lt;/code&gt;. Needs the card gap above closed first — or use &lt;em&gt;Connect from Foundry&lt;/em&gt; to bypass APIM for Foundry-to-Foundry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expose several agents.&lt;/strong&gt; One A2A API per agent, same policy with the name swapped. After two, script it (Bicep / apiops).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn agents into MCP tools.&lt;/strong&gt; A2A APIs can't be exposed as MCP servers directly. Create one plain HTTP API in APIM with one POST operation per agent pointing at each agent's Responses endpoint (&lt;code&gt;…/endpoint/protocols/openai/responses&lt;/code&gt;), then &lt;em&gt;Expose existing API as MCP server&lt;/em&gt;. One MCP server, one tool per agent, fully GA end-to-end.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A2A base URL     https://{account}.services.ai.azure.com/api/projects/{project}/agents/{agent}/endpoint/protocols/a2a
Card URL (v1.0)  …/protocols/a2a/agentCard/v1.0
Token scope      https://ai.azure.com/.default
Role             Foundry User (or Foundry Agent Consumer, ID eed3b665-ab3a-47b6-8f48-c9382fb1dad6)
Version header   A2A-Version: 1.0
JSON-RPC method  SendMessage   (v1.0)   |   message/send   (v0.3)
Reply path       result.task.artifacts[0].parts[0].text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ai</category>
      <category>azure</category>
    </item>
    <item>
      <title>Putting Azure OpenAI Behind an API Gateway: What the Docs Don't Tell You</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Thu, 03 Sep 2026 08:35:16 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/putting-azure-openai-behind-an-api-gateway-what-the-docs-dont-tell-you-10go</link>
      <guid>https://dev.to/vicky_acedia/putting-azure-openai-behind-an-api-gateway-what-the-docs-dont-tell-you-10go</guid>
      <description>&lt;p&gt;We had a problem that will sound familiar to anyone who has let a team loose on Azure OpenAI.&lt;/p&gt;

&lt;p&gt;One Foundry resource. One &lt;code&gt;gpt-4o-mini&lt;/code&gt; deployment. One API key, copied into a config file, then into a second config file, then pasted into a Slack thread so someone could test something quickly. No idea who was spending what. No limits. If one app got stuck in a retry loop at 2am, everybody's model calls would start failing and we'd spend the morning working out whose fault it was.&lt;/p&gt;

&lt;p&gt;The fix is an AI gateway. Azure API Management has a purpose-built wizard for this now, and the happy path genuinely is a wizard. But we hit roughly a dozen things that the documentation either glosses over or gets subtly wrong, and several of them are the kind of mistake you can't undo.&lt;/p&gt;

&lt;p&gt;This is what actually happened, in order, including the parts where I was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AI gateway buys you
&lt;/h2&gt;

&lt;p&gt;Before the mechanics, the argument, because it's easy to build this and then not enforce it.&lt;/p&gt;

&lt;p&gt;Putting API Management in front of a model deployment gets you four things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One key per application, revocable independently.&lt;/strong&gt; Each consuming app gets its own gateway key. Revoke one, the others keep working. No app ever holds a credential for the model itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spend caps that are actually enforced.&lt;/strong&gt; A tokens-per-minute limit per application means one runaway loop can't drain the shared quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attribution.&lt;/strong&gt; Token counts land in Application Insights, dimensioned by which app made the call. "Who spent this" becomes a query instead of an investigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching.&lt;/strong&gt; Semantically similar questions can be answered from a cache rather than paid for twice.&lt;/p&gt;

&lt;p&gt;Keep that list in mind, because there's a trap at the end of this post where you build all of it and enforce none of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: pick the right client compatibility mode
&lt;/h2&gt;

&lt;p&gt;The wizard's first real decision is also its most expensive to get wrong, and it's presented as three innocuous radio buttons.&lt;/p&gt;

&lt;p&gt;You are asked to choose between &lt;strong&gt;Azure OpenAI&lt;/strong&gt;, &lt;strong&gt;Azure AI&lt;/strong&gt;, and &lt;strong&gt;Azure OpenAI v1&lt;/strong&gt;. The difference is where the deployment name lives.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Azure OpenAI&lt;/strong&gt;, the deployment name stays in the URL path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST {gateway}/{base-path}/openai/deployments/{deployment}/chat/completions?api-version=2024-12-01-preview
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the other two, the deployment name moves into the request body. That sounds like a trivial difference. It isn't, because the official &lt;code&gt;AzureOpenAI&lt;/code&gt; Python SDK builds the first shape. Choose either of the other options and you are rewriting every client, not changing a URL.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Azure OpenAI&lt;/strong&gt; unless you have a specific reason not to. Your app code then changes by exactly one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AzureOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-12-01-preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;azure_endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-gateway.example.com/ai-prod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# was the Foundry URL
&lt;/span&gt;    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GATEWAY_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                          &lt;span class="c1"&gt;# was the Foundry key
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK appends &lt;code&gt;/openai/deployments/...&lt;/code&gt; itself.&lt;/p&gt;

&lt;p&gt;There's a bonus hidden in this choice. Because the deployment name is a path segment, it behaves as a wildcard. Deploy a new model on the same Foundry resource, and it is immediately reachable through the gateway with no new API, no second wizard run, no config change. We only discovered this by asking; it's not obvious from the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The base path is forever
&lt;/h2&gt;

&lt;p&gt;The wizard asks for a base path and leaves the field blank. Everything else on that screen you can change later. This one you effectively cannot, because it becomes part of every client's URL.&lt;/p&gt;

&lt;p&gt;Two rules. Make it lowercase and hyphenated, because base paths are case-sensitive and mixed case generates 404s that people waste an afternoon on. And make it specific rather than generic. &lt;code&gt;openai&lt;/code&gt; feels natural until you onboard a second Foundry resource and discover the name is taken on that gateway instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default token limit is a trap
&lt;/h2&gt;

&lt;p&gt;Tick "manage token consumption" and the TPM field pre-fills with &lt;strong&gt;1000&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Our deployment quota was 250,000 TPM. With &lt;code&gt;max_tokens: 4096&lt;/code&gt;, a single verbose response can consume a meaningful fraction of 1000 tokens, so you'll start seeing 429s within a handful of test calls and reasonably conclude the gateway is broken.&lt;/p&gt;

&lt;p&gt;Go and read your actual deployment quota first. Then set the gateway limit &lt;strong&gt;at or below&lt;/strong&gt; it. Above is worse than useless: API Management forwards the traffic, the model throws the 429 instead, and you get the outage without the protection, with the error surfacing from the backend where it's harder to attribute. We set ours to 125,000, half the quota, leaving room to onboard a second consumer without touching the deployment.&lt;/p&gt;

&lt;p&gt;Two settings on that screen deserve more attention than they get:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit by: Subscription.&lt;/strong&gt; This makes the allowance per gateway key, so each app has its own bucket. The alternative, IP address, is wrong for most deployments because your apps will call from a small pool of shared outbound addresses and collide into a single bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Estimate prompt tokens: on.&lt;/strong&gt; With it off, the policy can only count tokens after the response returns, so a burst of concurrent requests all pass the check before any of them report. Worse: for streamed responses it can't count at all unless the client sends &lt;code&gt;stream_options: {"include_usage": true}&lt;/code&gt;. Most client code doesn't. With estimation off, streaming calls quietly bypass your limit entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics dimensions: fewer than you think
&lt;/h2&gt;

&lt;p&gt;The wizard offers nine dimensions to slice token metrics by, and it's tempting to take them all. We did, briefly.&lt;/p&gt;

&lt;p&gt;Application Insights bills per unique combination of dimension values. Most of those dimensions are constants in a single-instance, single-API setup, so you pay for cardinality that tells you nothing.&lt;/p&gt;

&lt;p&gt;Two are worth having:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subscription ID&lt;/strong&gt;: which application spent the tokens. This is the chargeback dimension and the whole point of the exercise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API ID&lt;/strong&gt;: constant today, essential the moment you add a second Foundry resource.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip &lt;strong&gt;Client IP&lt;/strong&gt; (high cardinality, low information, since callers share outbound addresses), &lt;strong&gt;User ID&lt;/strong&gt; (empty unless you're doing per-user JWT auth at the gateway), and &lt;strong&gt;Gateway ID&lt;/strong&gt; / &lt;strong&gt;Location&lt;/strong&gt; (constant for a single instance).&lt;/p&gt;

&lt;p&gt;Worth knowing before you decide: Application Insights data isn't retroactive. Metrics already emitted keep their original dimensions. So adding a dimension later means old and new data can't be compared cleanly, and removing one leaves a split in your history. Get it roughly right at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  There are two different Application Insights connections
&lt;/h2&gt;

&lt;p&gt;This one cost us a debugging session.&lt;/p&gt;

&lt;p&gt;The token metric policy and the request telemetry logger are separate mechanisms, configured in different places, and having one does not give you the other.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;token metric policy&lt;/strong&gt; is set up in the wizard. It emits prompt, completion, and total token counts.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;request logger&lt;/strong&gt; is a per-API setting under Settings, Diagnostics Logs. It captures status codes, latency, which operation was called, and failures. It has its own destination field, which starts empty.&lt;/p&gt;

&lt;p&gt;So after the wizard we could see exactly how many tokens a request consumed, but not that it had returned a 500 or taken twelve seconds. When we hit our first error, there was nothing useful to look at.&lt;/p&gt;

&lt;p&gt;Set both. On the request logger, set sampling to 100% for a low-volume API, tick "always log errors", and switch the correlation protocol from Legacy to &lt;strong&gt;W3C&lt;/strong&gt;, which is what lets you trace a request end to end across the edge, the gateway, and the backend. Leave "payload bytes to log" at 0 unless you're actively debugging, because raising it puts prompt and response content into your logs.&lt;/p&gt;

&lt;p&gt;One more thing that surprised us: the request logger is instance-wide by default, so telemetry from unrelated APIs on the same gateway lands in the same Application Insights resource. That's not a bug. Operations are prefixed with the API ID, so filtering is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests
| where operation_Name startswith "your-ai-api"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only place it genuinely bites is alerting. An alert on "average duration over 5 seconds" is meaningless when one API responds in 100ms and the other takes 8 seconds. Scope alerts by operation name, not on the blended instance average.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantic caching is where the wizard falls apart
&lt;/h2&gt;

&lt;p&gt;This is the feature with the longest prerequisite chain, and the wizard will not tell you what's missing until you're several screens in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Your existing Redis almost certainly won't work
&lt;/h3&gt;

&lt;p&gt;Semantic caching needs a vector index, which means Redis with the &lt;strong&gt;RediSearch&lt;/strong&gt; module. We had a Redis instance already, running happily as a cache for another system. Its module list read &lt;code&gt;RedisJSON, RedisTimeSeries, RedisBloom&lt;/code&gt;. No RediSearch.&lt;/p&gt;

&lt;p&gt;Modules are fixed at creation time. You cannot add RediSearch to an existing instance. You create a new one.&lt;/p&gt;

&lt;h3&gt;
  
  
  RediSearch conflicts with OSS clustering
&lt;/h3&gt;

&lt;p&gt;Creating that new instance, we ticked RediSearch and immediately got:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;OSSCluster Cluster Policy doesn't support the selected module(s): RediSearch&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix is one click: change &lt;strong&gt;Clustering Policy&lt;/strong&gt; from &lt;code&gt;OSS&lt;/code&gt; to &lt;code&gt;Enterprise&lt;/code&gt;. Enterprise clustering hides sharding from the client, which is what the search module requires.&lt;/p&gt;

&lt;h3&gt;
  
  
  You can't set an eviction policy, and that matters
&lt;/h3&gt;

&lt;p&gt;With RediSearch enabled, the eviction policy dropdown offers exactly one option: &lt;strong&gt;No Eviction&lt;/strong&gt;. Azure locks it, because the search index needs its data present to stay consistent.&lt;/p&gt;

&lt;p&gt;The consequence is easy to miss. Your cache will not self-clean. When it fills, Redis starts &lt;strong&gt;rejecting writes&lt;/strong&gt; rather than dropping old entries. So the cache duration you set in the API Management policy stops being a tuning knob and becomes the only thing preventing unbounded growth. We used 3600 seconds. Size the instance with headroom.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;InsufficientCapacity&lt;/code&gt; is a region problem, not a config problem
&lt;/h3&gt;

&lt;p&gt;Our first three deployment attempts failed with a generic &lt;code&gt;Conflict&lt;/code&gt; / &lt;code&gt;ResourceDeploymentFailure&lt;/code&gt;. The portal's error summary was useless. The &lt;strong&gt;Raw Error&lt;/strong&gt; tab had the real answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"InsufficientCapacity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Request failed due to insufficient capacity. Retry using a different Azure Managed Redis size or region."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always open Raw Error. The summary is a wrapper.&lt;/p&gt;

&lt;p&gt;Two levers: change the size, or change the region. Note that changing size within the same performance tier often doesn't help, because those SKUs draw from the same capacity pool. Switching performance tier (Balanced to Memory Optimized, say) is more likely to find capacity than stepping from B0 to B1.&lt;/p&gt;

&lt;p&gt;We eventually provisioned in a different region, which leads directly to the next mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  The external cache has a region field, and it's not decorative
&lt;/h3&gt;

&lt;p&gt;Registering Redis as API Management's external cache asks you which gateway location "uses" this cache. Ours defaulted to the region the Redis instance was in, which was not the region the gateway was in. If no gateway location matches, the cache may never be used at all, silently. Set it to your gateway's region, or &lt;code&gt;Default&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also worth understanding: external cache registration is &lt;strong&gt;instance-wide&lt;/strong&gt;, but &lt;em&gt;using&lt;/em&gt; it is &lt;strong&gt;per-API&lt;/strong&gt;. Registering the cache doesn't change the behaviour of any existing API, because nothing touches Redis unless a policy says so.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-region caching may cost more than it saves
&lt;/h3&gt;

&lt;p&gt;We ended up with the gateway in one continent and Redis in another. Roughly 200ms of round trip added to &lt;strong&gt;every&lt;/strong&gt; request, hits and misses alike.&lt;/p&gt;

&lt;p&gt;On a cache miss, that's ~200ms added to a call already taking 1 to 2 seconds. Annoying, survivable. On a hit, you still come out well ahead. The place it genuinely hurts is streaming, where the full round trip lands on time-to-first-token, which is the latency users actually perceive.&lt;/p&gt;

&lt;p&gt;But the real question isn't latency. It's whether your prompts repeat at all. Semantic caching pays an embedding call on &lt;strong&gt;100%&lt;/strong&gt; of requests to get a saving on some fraction of them. If your traffic is mostly unique prompts, you're paying for nothing. Measure your hit rate before you defend the architecture.&lt;/p&gt;

&lt;p&gt;I should also correct something I believed going in. I assumed Redis Enterprise with search would cost several hundred dollars a month, and used that to argue against the whole feature. Azure Managed Redis at the entry tier came in around &lt;strong&gt;$25/month&lt;/strong&gt;. That's a materially different calculation, and it changed my recommendation. Check current pricing rather than trusting a number you remember.&lt;/p&gt;

&lt;h3&gt;
  
  
  The embeddings backend the wizard forgets to create
&lt;/h3&gt;

&lt;p&gt;This one produced our only hard failure.&lt;/p&gt;

&lt;p&gt;The semantic cache lookup policy references an embeddings backend by ID. That backend needs to exist as a registered API Management &lt;strong&gt;Backend&lt;/strong&gt;, pointing at an embeddings deployment. The wizard normally creates it for you.&lt;/p&gt;

&lt;p&gt;Ours didn't, because the wizard aborted partway through with a benign-looking error:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Error setting up semantic caching. The role assignment already exists.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The role assignment conflict was harmless. An earlier attempt had already granted the gateway's managed identity the right role. But the wizard treated it as fatal, stopped, and never created the backend or added the policies.&lt;/p&gt;

&lt;p&gt;We added the policies by hand and got a clean &lt;strong&gt;500 Internal Server Error&lt;/strong&gt; on every request, in about 800ms, far too fast to have reached the model. The lookup policy was throwing because &lt;code&gt;embeddings-backend&lt;/code&gt; didn't resolve.&lt;/p&gt;

&lt;p&gt;The fix: create the backend manually as a Custom URL, with the name matching the policy exactly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://&amp;lt;your-resource&amp;gt;.openai.azure.com/openai/deployments/text-embedding-3-small/embeddings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lessons. First, if the wizard reports &lt;em&gt;any&lt;/em&gt; error, go and verify what it actually created; don't assume a partial failure is a partial success. Second, and more generally: &lt;strong&gt;add one policy at a time and test between each&lt;/strong&gt;. We added two policies and a backend dependency in one go, then had to bisect. Getting a single successful &lt;code&gt;200&lt;/code&gt; before layering anything on would have made the failure obvious immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy order, and what it means
&lt;/h2&gt;

&lt;p&gt;The finished inbound chain, in execution order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate the gateway key&lt;/strong&gt; against the product. An unrecognised key is rejected here, before any Azure resource is touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic cache lookup.&lt;/strong&gt; Embed the prompt, search Redis. A match above the similarity threshold returns the stored answer and steps 3 and 4 never run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token rate limit.&lt;/strong&gt; Count the call against that key's allowance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Emit token metrics.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route to the backend&lt;/strong&gt; using the gateway's managed identity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And on the way out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Semantic cache store&lt;/strong&gt;, writing the answer and its vector back to Redis.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The sequencing is deliberate: cheap rejections first, cache before the expensive call, and no credential anywhere in the path.&lt;/p&gt;

&lt;p&gt;One thing worth internalising: a cache miss now costs you an &lt;strong&gt;embedding call plus a model call&lt;/strong&gt;. Add content safety screening and you have three service round trips before the model sees the prompt. That's the honest price of a governed gateway. It's usually worth it. Just don't be surprised by the latency and go looking for a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Similarity threshold: trust the description, not the warning
&lt;/h2&gt;

&lt;p&gt;Setting the threshold, the portal showed us this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Similarity score threshold above 0.2 may lead to cache mismatch. Consider using lower value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is backwards, at least as worded. The field description says similarity, where 1.0 is an exact match. A &lt;strong&gt;higher&lt;/strong&gt; threshold means stricter matching and fewer false hits. A &lt;strong&gt;lower&lt;/strong&gt; threshold is what causes mismatches: at 0.2 almost any two prompts would look similar and you'd cheerfully serve a user the answer to a question they didn't ask.&lt;/p&gt;

&lt;p&gt;We started at 0.9. Start strict, watch your hit rate, and lower it deliberately if hits are too rare. This is the one setting where a wrong value produces &lt;em&gt;plausible but incorrect&lt;/em&gt; output rather than an error, which makes it the most dangerous knob on the page.&lt;/p&gt;

&lt;p&gt;Also worth scoping the cache per key with a &lt;code&gt;vary-by&lt;/code&gt; on the subscription. Otherwise one application's answers can be served to another.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header rename that saves you a rewrite
&lt;/h2&gt;

&lt;p&gt;API Management expects its key in an &lt;code&gt;Ocp-Apim-Subscription-Key&lt;/code&gt; header. The &lt;code&gt;AzureOpenAI&lt;/code&gt; SDK sends credentials in an &lt;code&gt;api-key&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;You can work around this in client code with &lt;code&gt;default_headers&lt;/code&gt;, but there's a cleaner option: under the API's Settings, rename the subscription header to &lt;strong&gt;&lt;code&gt;api-key&lt;/code&gt;&lt;/strong&gt;. Now the SDK works unchanged and your app diff is genuinely one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Products and subscriptions: badly named, load-bearing
&lt;/h2&gt;

&lt;p&gt;The terminology here trips people up, so plainly:&lt;/p&gt;

&lt;p&gt;An API Management &lt;strong&gt;subscription&lt;/strong&gt; has nothing to do with an Azure subscription. It's an API key.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;product&lt;/strong&gt; is a bundle of APIs plus access rules. A &lt;strong&gt;subscription&lt;/strong&gt; is a key issued against a product. So: product contains your API, you create a subscription on the product, it generates a key, the app sends the key.&lt;/p&gt;

&lt;p&gt;This isn't optional decoration. Two of the settings you already configured point at nothing without it: &lt;code&gt;Limit by: Subscription&lt;/code&gt; has no subscriptions to count per, and the Subscription ID metrics dimension has nothing to record. And of course without a key nobody can call the API at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that makes all of this decorative
&lt;/h2&gt;

&lt;p&gt;Here's the one that matters most, and it's the easiest to skip because everything appears to be working.&lt;/p&gt;

&lt;p&gt;When you finish, you have a governed gateway. You also still have the original Foundry endpoint, with its original keys, publicly reachable, bypassing every control you just built. Anyone holding a copy of that key gets no rate limit, no metrics, no cache, no attribution.&lt;/p&gt;

&lt;p&gt;Your gateway is optional. And an optional control is not a control.&lt;/p&gt;

&lt;p&gt;There are two ways to close it, with very different effort profiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disable local key authentication.&lt;/strong&gt; The endpoint stays publicly reachable, so the gateway keeps working unchanged (it authenticates with its managed identity, not a key), but the API keys stop working entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az cognitiveservices account update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;your-foundry-resource&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;your-rg&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--custom-domain&lt;/span&gt; &amp;lt;your-foundry-resource&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-properties&lt;/span&gt; &lt;span class="nv"&gt;disableLocalAuth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Or disable public network access&lt;/strong&gt; and put a private endpoint in front of it. Stronger, because it removes the public attack surface entirely rather than just the key path. But it's a real project: private endpoint, private DNS zone linked to the VNet, and gateway VNet integration, which requires a Standard v2 or Premium v2 tier. If you're on a lower tier, this becomes a SKU migration with a cost attached.&lt;/p&gt;

&lt;p&gt;If you go the private route, &lt;strong&gt;audit callers before disabling local auth&lt;/strong&gt;, and do it last in the sequence. If some forgotten script or integration is hitting the model with a key today, it breaks the moment you flip that switch, silently and at whatever time of day you happened to run the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prove the base path works before adding anything.&lt;/strong&gt; One successful &lt;code&gt;200&lt;/code&gt; through the gateway, with nothing but key validation in the chain. Then add the token limit, test. Then metrics, test. Then caching, test. We stacked four unproven dependencies and spent longer bisecting than the sequential approach would have taken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the raw error, always.&lt;/strong&gt; The portal's error summaries are wrappers around the useful message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify what the wizard claims to have built.&lt;/strong&gt; A partial failure looks a lot like success from the notifications panel. Check the policy XML. Check the backends list. Check the role assignment landed on the target resource.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide about direct access on day one.&lt;/strong&gt; Not as a follow-up task. Until the bypass is closed, you have built a dashboard, not a gateway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask the data residency question out loud.&lt;/strong&gt; Semantic caching stores prompt text and responses. If your organisation deliberately chose a region for compliance reasons, a cache in a different geography is a real change to where data lives, not an implementation detail. It's the item least likely to surface on its own and most likely to become a problem later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, and not marginally.&lt;/p&gt;

&lt;p&gt;The end state: every application holds its own revocable key, no application holds a credential for the model, spend is capped per application and attributable to the team that caused it, repeated questions are served from cache instead of being paid for twice, and there's one place to look when something breaks.&lt;/p&gt;

&lt;p&gt;The build took an afternoon. Most of that afternoon was the dozen small things above, none of which are in the quickstart. Now that you know about them, it should take you an hour.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>ai</category>
    </item>
    <item>
      <title>Promotable, Deployable, Rotatable: Making Bench Management an Actual Process</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:57:52 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/promotable-deployable-rotatable-making-bench-management-an-actual-process-5b2a</link>
      <guid>https://dev.to/vicky_acedia/promotable-deployable-rotatable-making-bench-management-an-actual-process-5b2a</guid>
      <description>&lt;p&gt;Every services organisation has a bench — people between projects, waiting on a client start date, rolling off an engagement that ended early, or parked because the skill they have and the skill the market wants have drifted apart. The bench is not a defect. It's the cost of being able to say yes to work you haven't won yet.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; a defect is not knowing anything useful about the people on it.&lt;/p&gt;

&lt;p&gt;The usual state of affairs is a spreadsheet with a name, a skill string, a rate, and a date. That tells you who is unallocated. It tells you nothing about what to &lt;em&gt;do&lt;/em&gt; about it. So the conversation in the weekly review becomes a series of individual negotiations, driven by whoever in the room happens to remember something about that person. Decisions don't accumulate. Six weeks later you have the same conversation about the same person, and nobody can reconstruct why the last one went the way it did.&lt;/p&gt;

&lt;p&gt;PDR is one way out of that. It's a small idea, and most of the value is in the discipline it forces rather than the taxonomy itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three dispositions
&lt;/h2&gt;

&lt;p&gt;PDR classifies each person on the bench against three questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Promotable&lt;/strong&gt; — is this person ready to move up a level? Not "are they good," but: is there evidence they are already operating above their current band, and would a promotion make them easier to place at a higher billing rate?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployable&lt;/strong&gt; — can this person be put on billable work as-is, right now, with no intervention? This is the most misread of the three. Deployable is not a compliment. It means the skills match live demand and there is nothing blocking a start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotatable&lt;/strong&gt; — should this person move to a different technology, domain, or vertical? Rotation is the answer when someone is capable but their current specialisation has no pipeline behind it.&lt;/p&gt;

&lt;p&gt;The point of three separate axes is that they are not mutually exclusive and they are not a ranking. Someone can be promotable &lt;em&gt;and&lt;/em&gt; rotatable — a strong senior engineer in a dying stack. Someone can be deployable but not promotable — solid, well-matched, not ready for the next band. Someone can be none of the three, and that is the most important signal the framework produces, because it means the situation needs a decision that isn't "wait."&lt;/p&gt;

&lt;p&gt;The moment you collapse this into a single label — a tier, a grade, an A/B/C — you lose exactly the information you built the system to capture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The process
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Intake
&lt;/h3&gt;

&lt;p&gt;Bench entries come from allocation data, not from manual entry. If someone has to remember to add a person, the list will be wrong within a week. The system should derive the bench from the absence of active billable allocation, then let humans annotate.&lt;/p&gt;

&lt;p&gt;Intake also needs a start date for the bench period. Aging is the single most predictive field you have, and it only works if the clock starts automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Assessment
&lt;/h3&gt;

&lt;p&gt;Someone with actual knowledge of the person marks the three flags. This is usually a delivery lead or a resource manager, not HR and not a tool.&lt;/p&gt;

&lt;p&gt;Two design decisions matter more than they sound:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unassessed must look different from assessed-and-negative.&lt;/strong&gt; "Not promotable" and "nobody has looked at this yet" are completely different facts, and a UI that renders both as an empty checkbox will quietly destroy your data quality. Blank means unknown. Show it as unfinished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every negative or unusual combination needs a justification.&lt;/strong&gt; Not a dropdown — free text, required, at the point of assessment. This is the gate that turns the framework from a labelling exercise into a decision record. It also slows people down, which is the intended effect. If marking someone as not deployable takes two seconds, it will be done thoughtlessly and often.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Action
&lt;/h3&gt;

&lt;p&gt;A classification with no downstream action is theatre. Each disposition should route somewhere concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Promotable → into the next promotion cycle with the justification attached as evidence&lt;/li&gt;
&lt;li&gt;Deployable → into active matching against open demand&lt;/li&gt;
&lt;li&gt;Rotatable → into a reskilling track with a named target skill and a timeline&lt;/li&gt;
&lt;li&gt;None of the above → escalation, with a review date&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fourth case is the one organisations avoid building. It's also the one that determines whether the whole exercise is honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Audit
&lt;/h3&gt;

&lt;p&gt;Every assessment change is appended, never overwritten. Who changed what, when, from what to what, and why. Append-only, no edits, no deletes.&lt;/p&gt;

&lt;p&gt;This is non-negotiable for two reasons. The obvious one is that these records touch promotion, reassignment, and eventually separation decisions, and you will be asked to explain them. The less obvious one is that the history is where the actual insight lives. A person marked rotatable four quarters running, with four justifications, is telling you something about your reskilling programme, not about the person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Coverage gets measured instead of accuracy.&lt;/strong&gt; "94% of the bench is assessed" is a number that goes up when people click things. It says nothing about whether the assessments are right. Coverage is a hygiene metric — worth a single indicator, not a dashboard. If your reporting makes coverage the headline, you have built an incentive to fill in boxes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployable becomes the default.&lt;/strong&gt; Marking someone deployable is the path of least resistance: it's positive, it needs no justification, it moves the problem to the matching team. Watch the distribution. If deployable is running above 70% of assessed bench while placement rates stay flat, the flag has stopped meaning anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assessments go stale.&lt;/strong&gt; A three-month-old assessment is a guess. Build in expiry — an assessment older than a defined window reverts to unassessed rather than continuing to display as fact. People will hate this. Do it anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotation is recommended without capacity to deliver it.&lt;/strong&gt; Marking someone rotatable is free. Actually retraining them costs money, bench time, and a mentor. If rotation recommendations exceed reskilling capacity by 5x, the flag is a way of deferring a harder conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The framework gets used as a performance tool.&lt;/strong&gt; PDR describes &lt;em&gt;market fit and placement readiness&lt;/em&gt;. It is not a performance rating, and the two must not be joined. A high performer in a technology with no demand is not deployable, and if your system lets that read as a performance signal, you will lose good people and deserve to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest framing
&lt;/h2&gt;

&lt;p&gt;PDR is a decision-forcing device, not an optimisation engine. It won't tell you what to do with anyone. What it does is make the absence of a decision visible — an unassessed row, a stale assessment, a person marked rotatable for three quarters with no training assigned. That visibility is uncomfortable, which is the entire point, and it's also the reason most implementations quietly drift toward measuring completion rates instead.&lt;/p&gt;

&lt;p&gt;If you're building something like this, the test isn't whether the dashboard looks good. It's whether the weekly bench review is shorter and produces fewer repeat conversations than it did before. If a person's situation comes up twice with no change in between, the system failed regardless of how much of the bench is "covered."&lt;/p&gt;

&lt;p&gt;One last thing worth saying plainly: these are people, and the labels are blunt. "Not deployable" is a statement about a market, a skills pipeline, and a sales pipeline — three things the individual mostly doesn't control. Build the justification field wide, require it, and read what people write in it. That text is usually a more accurate description of your organisation's problems than of the person's.&lt;/p&gt;

</description>
      <category>process</category>
    </item>
    <item>
      <title>CI/CD for Azure Logic Apps Standard on a Private (ILB) ASE — Without a VNet Agent</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Wed, 26 Aug 2026 04:19:58 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/cicd-for-azure-logic-apps-standard-on-a-private-ilb-ase-without-a-vnet-agent-1j2e</link>
      <guid>https://dev.to/vicky_acedia/cicd-for-azure-logic-apps-standard-on-a-private-ilb-ase-without-a-vnet-agent-1j2e</guid>
      <description>&lt;p&gt;&lt;em&gt;How we wired an Azure DevOps pipeline to a Logic App Standard running on an internal App Service Environment, hit six real-world failures on the way, and ended with a deployment that needs no SAS tokens, no SCM access, and no VNet-connected build agent.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Logic Apps Standard on an &lt;strong&gt;ILB App Service Environment (ASE v3)&lt;/strong&gt; has no public SCM (Kudu) endpoint. That kills the two "normal" deployment routes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Why it fails on ILB ASE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;az webapp deploy&lt;/code&gt; / zip-deploy from a Microsoft-hosted agent&lt;/td&gt;
&lt;td&gt;Talks to the SCM endpoint, which is private&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code / portal publish&lt;/td&gt;
&lt;td&gt;Same SCM dependency, plus it isn't CI/CD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The usual answer is "stand up a self-hosted agent inside the VNet." That's the right long-term answer — but you can ship &lt;strong&gt;today&lt;/strong&gt; without it, using &lt;strong&gt;Run-From-Package with a managed-identity-authenticated blob URL&lt;/strong&gt;. The pipeline only ever talks to two public planes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Azure Storage&lt;/strong&gt; (upload the package)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ARM / management.azure.com&lt;/strong&gt; (set one app setting, restart the app)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app then pulls the package itself, from inside the VNet, using its own managed identity. SCM is never touched. No SAS token ever exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Git push ──▶ ADO pipeline ──▶ zip ──▶ Blob Storage
                                          ▲
                                          │ (pull via app's
                                          │  managed identity)
             ARM: set app setting ──▶ Logic App (ILB ASE)
             ARM: restart app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What you need before starting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Prerequisite&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Logic App Standard resource, &lt;strong&gt;Started&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;A stopped app silently accepts settings and deploys nothing. Check the Overview blade — ours had been sitting in &lt;code&gt;Stopped&lt;/code&gt; and cost us a confused hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;A storage account the ASE can reach&lt;/td&gt;
&lt;td&gt;Same region as the ASE. Public network access enabled is the simple path; a private endpoint works too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;A blob container, private access&lt;/td&gt;
&lt;td&gt;e.g. &lt;code&gt;deployments&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;An ADO agent (hosted or self-hosted) with &lt;strong&gt;Azure CLI installed&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Ours was a Windows self-hosted box that did &lt;em&gt;not&lt;/em&gt; have &lt;code&gt;az&lt;/code&gt; — see failure #3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;An ARM service connection&lt;/td&gt;
&lt;td&gt;Any auth type works. If it's the &lt;strong&gt;"Managed identity (agent-assigned)"&lt;/strong&gt; type, the identity must actually be enabled on the agent VM — see failure #2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Rights to create role assignments on the storage account and the Logic App's resource group&lt;/td&gt;
&lt;td&gt;Or a friendly admin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Step 1 — Repository layout
&lt;/h2&gt;

&lt;p&gt;One folder per Logic App Standard resource; one subfolder per workflow. The zip we deploy is simply the app folder's contents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;logicapps-platform/&lt;/span&gt;
&lt;span class="s"&gt;├── apps/&lt;/span&gt;
&lt;span class="s"&gt;│   └── la-integration-prod/          ← 1 folder = 1 Logic App Standard resource&lt;/span&gt;
&lt;span class="s"&gt;│       ├── host.json                 ← runtime + extension bundle&lt;/span&gt;
&lt;span class="s"&gt;│       ├── connections.json          ← connector references ({} to start)&lt;/span&gt;
&lt;span class="s"&gt;│       ├── parameters.json           ← per-environment values ({} to start)&lt;/span&gt;
&lt;span class="s"&gt;│       ├── .funcignore&lt;/span&gt;
&lt;span class="s"&gt;│       └── Heartbeat-Test/           ← 1 folder = 1 workflow&lt;/span&gt;
&lt;span class="s"&gt;│           └── workflow.json&lt;/span&gt;
&lt;span class="s"&gt;└── pipelines/&lt;/span&gt;
    &lt;span class="s"&gt;└── deploy-la-integration-prod.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;host.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensionBundle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Microsoft.Azure.Functions.ExtensionBundle.Workflows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[1.*, 2.0.0)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connections.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"managedApiConnections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"serviceProviderConnections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Heartbeat-Test/workflow.json&lt;/code&gt; — a deliberately trivial pilot workflow. No connectors, no secrets, nothing that can fail for reasons unrelated to the pipeline. Prove the pipe first; migrate real workflows second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"definition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contentVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"triggers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Every_Hour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Recurrence"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"recurrence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"frequency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Compose_Heartbeat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Compose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deployed via ADO pipeline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"runAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@{utcNow()}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"workflow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@{workflow().name}"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"runAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stateful"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Path gotcha:&lt;/strong&gt; if your files live in a subfolder of the repo (e.g. &lt;code&gt;myrepo/logicapps-platform/apps/...&lt;/code&gt;), the pipeline's &lt;code&gt;sourceFolder&lt;/code&gt; variable and trigger paths must include that prefix. Our first run failed with &lt;code&gt;Cannot find path ...\apps\&lt;/code&gt; for exactly this reason.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2 — Identity and role assignments
&lt;/h2&gt;

&lt;p&gt;Three grants total. Two for the &lt;strong&gt;pipeline's identity&lt;/strong&gt; (whatever your service connection authenticates as), one for the &lt;strong&gt;Logic App's own identity&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2a. Pipeline identity
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Storage Blob Data Contributor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The storage account&lt;/td&gt;
&lt;td&gt;Upload the package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Website Contributor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Logic App's resource group&lt;/td&gt;
&lt;td&gt;Set app settings + restart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Storage Blob Data Contributor"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-object-id&lt;/span&gt; &amp;lt;pipeline-identity-object-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-principal-type&lt;/span&gt; ServicePrincipal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"/subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/&amp;lt;storage-rg&amp;gt;/providers/Microsoft.Storage/storageAccounts/&amp;lt;storage-account&amp;gt;"&lt;/span&gt;

az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Website Contributor"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-object-id&lt;/span&gt; &amp;lt;pipeline-identity-object-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-principal-type&lt;/span&gt; ServicePrincipal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"/subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/&amp;lt;logicapp-rg&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How to find the pipeline identity's object ID when nobody remembers it:&lt;/strong&gt; run a throwaway pipeline on the target pool that asks the instance metadata service directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SelfHosted-Windows-Pool'&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;powershell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;$r = Invoke-RestMethod -Headers @{Metadata="true"} -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&amp;amp;resource=https://management.azure.com/"&lt;/span&gt;
    &lt;span class="s"&gt;$p = $r.access_token.Split('.')[1].Replace('-','+').Replace('_','/')&lt;/span&gt;
    &lt;span class="s"&gt;switch ($p.Length % 4) { 2 {$p+='=='}; 3 {$p+='='} }&lt;/span&gt;
    &lt;span class="s"&gt;$c = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json&lt;/span&gt;
    &lt;span class="s"&gt;Write-Host "objectId : $($c.oid)"&lt;/span&gt;
    &lt;span class="s"&gt;Write-Host "resource : $($c.xms_mirid)"   # full ARM ID of the VM - tells you where it lives&lt;/span&gt;
  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Who am I&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this returns &lt;code&gt;Identity not found&lt;/code&gt;, the agent VM's system-assigned identity is &lt;strong&gt;off&lt;/strong&gt; — enable it (VM → Identity → System assigned → On) and note the object ID it produces. This exact thing happened to us: the MI-type service connection had existed for months and had &lt;em&gt;never worked&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2b. Logic App identity (the key to SAS-free deployment)
&lt;/h3&gt;

&lt;p&gt;Enable the app's system-assigned identity (Logic App → Identity → System assigned → On), then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Storage Blob Data Reader"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-object-id&lt;/span&gt; &amp;lt;logicapp-identity-object-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-principal-type&lt;/span&gt; ServicePrincipal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"/subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/&amp;lt;storage-rg&amp;gt;/providers/Microsoft.Storage/storageAccounts/&amp;lt;storage-account&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;RBAC propagation is real.&lt;/strong&gt; Allow ~5 minutes between creating a role assignment and testing it. Restarting the app 30 seconds after the grant gives a false failure.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3 — The pipeline
&lt;/h2&gt;

&lt;p&gt;The complete, working YAML. Written for a &lt;strong&gt;Windows&lt;/strong&gt; agent (all steps PowerShell); on a Linux agent, switch &lt;code&gt;scriptType: ps&lt;/code&gt; to &lt;code&gt;bash&lt;/code&gt; and adjust the validation step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ============================================================================&lt;/span&gt;
&lt;span class="c1"&gt;# Deploy Logic App Standard via Run-From-Package (Blob, MI-authenticated URL)&lt;/span&gt;
&lt;span class="c1"&gt;# The app pulls the package with ITS OWN managed identity: no SAS, no expiry,&lt;/span&gt;
&lt;span class="c1"&gt;# and the SCM endpoint is never touched, so an ILB ASE is not a problem.&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# PREREQS:&lt;/span&gt;
&lt;span class="c1"&gt;#   - Logic App system-assigned identity ON, with 'Storage Blob Data Reader'&lt;/span&gt;
&lt;span class="c1"&gt;#     on the storage account&lt;/span&gt;
&lt;span class="c1"&gt;#   - Pipeline identity: 'Storage Blob Data Contributor' on the storage&lt;/span&gt;
&lt;span class="c1"&gt;#     account and 'Website Contributor' on the Logic App's resource group&lt;/span&gt;
&lt;span class="c1"&gt;# ============================================================================&lt;/span&gt;

&lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apps/la-integration-prod/**&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pipelines/**&lt;/span&gt;

&lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SelfHosted-Windows-Pool'&lt;/span&gt;     &lt;span class="c1"&gt;# or vmImage: 'ubuntu-latest' for SP-auth connections&lt;/span&gt;

&lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;serviceConnection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;my-azure-service-connection'&lt;/span&gt;
  &lt;span class="na"&gt;resourceGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rg-logicapps-prod'&lt;/span&gt;
  &lt;span class="na"&gt;appName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;la-integration-prod'&lt;/span&gt;
  &lt;span class="na"&gt;storageAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;stdeploypkgs'&lt;/span&gt;
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deployments'&lt;/span&gt;
  &lt;span class="na"&gt;sourceFolder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;apps/la-integration-prod'&lt;/span&gt;
  &lt;span class="na"&gt;packageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;la-integration-prod-$(Build.BuildId).zip'&lt;/span&gt;

&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="c1"&gt;# ----------------------------------------------------------------------------&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate &amp;amp; package&lt;/span&gt;
  &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Package&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;powershell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;$bad = 0&lt;/span&gt;
        &lt;span class="s"&gt;Get-ChildItem -Path "$(sourceFolder)" -Recurse -Filter *.json | ForEach-Object {&lt;/span&gt;
          &lt;span class="s"&gt;$raw = Get-Content $_.FullName -Raw&lt;/span&gt;
          &lt;span class="s"&gt;try { $raw | ConvertFrom-Json | Out-Null }&lt;/span&gt;
          &lt;span class="s"&gt;catch { Write-Host "##vso[task.logissue type=error]Invalid JSON: $($_.FullName)"; $bad++ }&lt;/span&gt;
          &lt;span class="s"&gt;if ($raw -match '"(secret|client_secret|clientSecret|password)"\s*:\s*"(?!@)') {&lt;/span&gt;
            &lt;span class="s"&gt;Write-Host "##vso[task.logissue type=error]Inline secret in: $($_.FullName)"; $bad++&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;if ($bad -gt 0) { exit 1 }&lt;/span&gt;
      &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate JSON + block inline secrets&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ArchiveFiles@2&lt;/span&gt;
      &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Zip app content&lt;/span&gt;
      &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;rootFolderOrFile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$(sourceFolder)'&lt;/span&gt;
        &lt;span class="na"&gt;includeRootFolder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;          &lt;span class="c1"&gt;# host.json must sit at the ZIP ROOT&lt;/span&gt;
        &lt;span class="na"&gt;archiveType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zip&lt;/span&gt;
        &lt;span class="na"&gt;archiveFile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$(Build.ArtifactStagingDirectory)/$(packageName)'&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$(Build.ArtifactStagingDirectory)/$(packageName)'&lt;/span&gt;
      &lt;span class="na"&gt;artifact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;package&lt;/span&gt;

&lt;span class="c1"&gt;# ----------------------------------------------------------------------------&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to $(appName)&lt;/span&gt;
  &lt;span class="na"&gt;dependsOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
  &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;logicapps-prod'&lt;/span&gt;         &lt;span class="c1"&gt;# add an approval gate on this environment&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;runOnce&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;download&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;current&lt;/span&gt;
            &lt;span class="na"&gt;artifact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;package&lt;/span&gt;

          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AzureCLI@2&lt;/span&gt;
            &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload package to blob&lt;/span&gt;
            &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;azureSubscription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$(serviceConnection)'&lt;/span&gt;
              &lt;span class="na"&gt;scriptType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ps&lt;/span&gt;
              &lt;span class="na"&gt;scriptLocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inlineScript&lt;/span&gt;
              &lt;span class="na"&gt;inlineScript&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
                &lt;span class="s"&gt;az storage blob upload `&lt;/span&gt;
                  &lt;span class="s"&gt;--account-name $(storageAccount) `&lt;/span&gt;
                  &lt;span class="s"&gt;--container-name $(container) `&lt;/span&gt;
                  &lt;span class="s"&gt;--name $(packageName) `&lt;/span&gt;
                  &lt;span class="s"&gt;--file "$(Pipeline.Workspace)/package/$(packageName)" `&lt;/span&gt;
                  &lt;span class="s"&gt;--auth-mode login --overwrite&lt;/span&gt;

          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AzureCLI@2&lt;/span&gt;
            &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Point app at package (MI-auth URL, no SAS, no SCM)&lt;/span&gt;
            &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;azureSubscription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$(serviceConnection)'&lt;/span&gt;
              &lt;span class="na"&gt;scriptType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ps&lt;/span&gt;
              &lt;span class="na"&gt;scriptLocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inlineScript&lt;/span&gt;
              &lt;span class="na"&gt;inlineScript&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
                &lt;span class="s"&gt;$ErrorActionPreference = "Stop"&lt;/span&gt;
                &lt;span class="s"&gt;$url = "https://$(storageAccount).blob.core.windows.net/$(container)/$(packageName)"&lt;/span&gt;

                &lt;span class="s"&gt;az webapp config appsettings set `&lt;/span&gt;
                  &lt;span class="s"&gt;-g "$(resourceGroup)" -n "$(appName)" `&lt;/span&gt;
                  &lt;span class="s"&gt;--settings WEBSITE_RUN_FROM_PACKAGE="$url" WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID="SystemAssigned" `&lt;/span&gt;
                  &lt;span class="s"&gt;-o none&lt;/span&gt;

                &lt;span class="s"&gt;az webapp restart -g "$(resourceGroup)" -n "$(appName)"&lt;/span&gt;
                &lt;span class="s"&gt;Write-Host "Deployed $(packageName). App restarted."&lt;/span&gt;

          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AzureCLI@2&lt;/span&gt;
            &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify workflow exists&lt;/span&gt;
            &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;azureSubscription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$(serviceConnection)'&lt;/span&gt;
              &lt;span class="na"&gt;scriptType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ps&lt;/span&gt;
              &lt;span class="na"&gt;scriptLocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inlineScript&lt;/span&gt;
              &lt;span class="na"&gt;inlineScript&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
                &lt;span class="s"&gt;Start-Sleep -Seconds 60&lt;/span&gt;
                &lt;span class="s"&gt;$sub = az account show --query id -o tsv&lt;/span&gt;
                &lt;span class="s"&gt;az rest --method GET `&lt;/span&gt;
                  &lt;span class="s"&gt;--uri "https://management.azure.com/subscriptions/$sub/resourceGroups/$(resourceGroup)/providers/Microsoft.Web/sites/$(appName)/workflows?api-version=2022-03-01" `&lt;/span&gt;
                  &lt;span class="s"&gt;--query "value[].name" -o tsv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines carry the whole trick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt; = the &lt;strong&gt;plain&lt;/strong&gt; blob URL — no SAS query string.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID=SystemAssigned&lt;/code&gt; — tells App Service to fetch the package using the app's own managed identity. &lt;strong&gt;Without this setting the runtime attempts anonymous access, fails, and the app reports &lt;code&gt;ServiceUnavailable&lt;/code&gt; from the host runtime.&lt;/strong&gt; This is the single most-missed step.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;includeRootFolder: false&lt;/code&gt; in the archive task — &lt;code&gt;host.json&lt;/code&gt; must be at the root of the zip, not nested one level down.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 4 — Run it, and what "success" looks like
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create the pipeline from the YAML, run it. First run will pause to ask permission on the service connection / environment / pool — click &lt;strong&gt;Permit&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Green pipeline, and the Verify step prints your workflow name(s).&lt;/li&gt;
&lt;li&gt;Portal → Logic App → &lt;strong&gt;Workflows&lt;/strong&gt; shows the workflow, &lt;code&gt;Stateful&lt;/code&gt;, &lt;code&gt;Enabled&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Open it → &lt;strong&gt;Run history&lt;/strong&gt; → confirm a run actually executed with the expected output. A workflow that loads but never runs is not success.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Everything that actually went wrong (the useful part)
&lt;/h2&gt;

&lt;p&gt;Our path to green, in order. If you're debugging, scan this table first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Root cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unable to locate executable file: 'bash'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Assumed Linux; the self-hosted agent was &lt;strong&gt;Windows&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;All steps → PowerShell (&lt;code&gt;scriptType: ps&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Invoke-RestMethod : {"error":"invalid_request","error_description":"Identity not found"}&lt;/code&gt; on the metadata endpoint&lt;/td&gt;
&lt;td&gt;Agent VM's system-assigned managed identity was &lt;strong&gt;off&lt;/strong&gt; — the MI-type service connection had never actually worked&lt;/td&gt;
&lt;td&gt;VM → Identity → System assigned → On. If the identity was enabled after boot, a VM restart may be needed before the token endpoint responds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Azure CLI 2.x is not installed on this machine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fresh agent VM, no &lt;code&gt;az&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Install via portal &lt;strong&gt;Run Command&lt;/strong&gt; (no RDP needed), then restart the agent service so it picks up the new PATH:&lt;br&gt;&lt;code&gt;Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile C:\az.msi&lt;/code&gt;&lt;br&gt;&lt;code&gt;Start-Process msiexec.exe -ArgumentList '/i C:\az.msi /quiet /norestart' -Wait&lt;/code&gt;&lt;br&gt;`Get-Service vstsagent* \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;Get-ChildItem : Cannot find path '...\apps\'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Repo files nested one folder deeper than the YAML expected&lt;/td&gt;
&lt;td&gt;Fix &lt;code&gt;sourceFolder&lt;/code&gt; and trigger &lt;code&gt;paths&lt;/code&gt; to include the prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ERROR: incorrect usage: --expiry should be within 7 days from now&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Identity-based (user-delegation) SAS is capped at &lt;strong&gt;7 days&lt;/strong&gt; — and a 7-day SAS would be a time bomb anyway, since the app re-reads the package URL on every restart&lt;/td&gt;
&lt;td&gt;Drop SAS entirely; switch to the MI-authenticated plain URL (this article's approach)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Pipeline green, but portal shows &lt;code&gt;Error retrieving workflows. Encountered an error (ServiceUnavailable) from host runtime&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;App couldn't pull the package: &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID&lt;/code&gt; missing, &lt;strong&gt;and&lt;/strong&gt; the app's identity had no role on storage&lt;/td&gt;
&lt;td&gt;Add the app setting; grant &lt;code&gt;Storage Blob Data Reader&lt;/code&gt; to the app's identity; wait ~5 min for RBAC; restart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A 60-second diagnostic when the runtime won't start
&lt;/h3&gt;

&lt;p&gt;Run from any machine with &lt;code&gt;az&lt;/code&gt; — checks the four usual suspects at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$rg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"rg-logicapps-prod"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"la-integration-prod"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"stdeploypkgs"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$strg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"rg-storage"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;sub-id&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 1. Identity on?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;webapp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;principalId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tsv&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 2. Both settings present?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;webapp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;appsettings&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[?starts_with(name,'WEBSITE_RUN_FROM')].{n:name,v:value}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 3. App identity has a role on storage?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;assignment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--assignee&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;principalId-from-step-1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/subscriptions/&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="s2"&gt;/resourceGroups/&lt;/span&gt;&lt;span class="nv"&gt;$strg&lt;/span&gt;&lt;span class="s2"&gt;/providers/Microsoft.Storage/storageAccounts/&lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[].roleDefinitionName"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tsv&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 4. Storage reachable at all?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$strg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{publicNetworkAccess:publicNetworkAccess, defaultAction:networkRuleSet.defaultAction}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever check comes back empty is your culprit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this beats the alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SAS URL in &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Works, but user-delegation SAS caps at 7 days and account-key SAS means handling keys. Either way the app dies quietly when the token expires — on whatever future day it happens to restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zip-deploy via VNet self-hosted agent&lt;/td&gt;
&lt;td&gt;The classic answer. Fine — but it makes deployment hostage to one VM's health, and you still shouldn't need SCM for a package-based app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MI-authenticated Run-From-Package&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No secrets, no expiry, no SCM, works from any agent that can reach ARM + Storage. The package is also immutable per build ID, so rollback = point the setting at the previous zip and restart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Hardening checklist for after the pilot
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Approval gate on the ADO Environment before the Deploy stage.&lt;/li&gt;
&lt;li&gt;Private endpoint on the storage account once the pattern is proven (the app pulls over the VNet; the pipeline needs its own route — service endpoint or an agent with access).&lt;/li&gt;
&lt;li&gt;Lifecycle policy on the container — build-numbered zips accumulate forever otherwise.&lt;/li&gt;
&lt;li&gt;Keep the inline-secret linter. It costs nothing and blocks the exact class of mistake that infests legacy workflow definitions.&lt;/li&gt;
&lt;li&gt;New workflows that replace still-running legacy ones should ship &lt;strong&gt;disabled&lt;/strong&gt; and be enabled at cutover — never let two schedulers fire the same job.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Total elapsed time for us, including all six failures: one working session. Total time if you follow this article: about 30 minutes — 25 of which are waiting for an MSI installer and RBAC propagation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>logicapps</category>
      <category>azure</category>
    </item>
    <item>
      <title># CSPM, CWPP, CIEM, CNAPP, EASM: Decoding the Cloud Security Alphabet</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:30:21 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/-cspm-cwpp-ciem-cnapp-easm-decoding-the-cloud-security-alphabet-2849</link>
      <guid>https://dev.to/vicky_acedia/-cspm-cwpp-ciem-cnapp-easm-decoding-the-cloud-security-alphabet-2849</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of 4 in the "Security Testing Landscape" series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The cloud security market has produced some of the worst acronyms in IT history. Here's the secret that makes them all click: &lt;strong&gt;these aren't tests — they're continuous monitoring platforms, and each one watches a different layer of your cloud.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The analogy that makes it permanent: &lt;strong&gt;your cloud is a house.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  CSPM — Cloud Security Posture Management
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Are the doors and windows locked?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;CSPM watches your cloud &lt;strong&gt;configuration&lt;/strong&gt;: public storage accounts, wide-open network security groups, disabled encryption, subscriptions without MFA. It continuously scans against benchmarks (CIS, well-architected frameworks) and flags drift the moment someone loosens a setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answers:&lt;/strong&gt; "Is my cloud set up wrong?"&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; Microsoft Defender for Cloud, Wiz, Prisma Cloud, Orca&lt;/p&gt;

&lt;h2&gt;
  
  
  CWPP — Cloud Workload Protection Platform
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Is there an intruder inside a room?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;CWPP watches the &lt;strong&gt;workloads themselves&lt;/strong&gt; — VMs, containers, serverless functions: vulnerabilities inside them, malware, suspicious runtime behavior, file integrity changes. Think of it as EDR, but for cloud compute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answers:&lt;/strong&gt; "Is something bad running inside my machines?"&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; Defender for Servers/Containers, CrowdStrike Falcon Cloud Security, Aqua, SentinelOne&lt;/p&gt;

&lt;h2&gt;
  
  
  CIEM — Cloud Infrastructure Entitlement Management
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Who has too many keys?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;CIEM watches &lt;strong&gt;identities and permissions&lt;/strong&gt;: which users, service principals, and managed identities can do what. It hunts over-permissioned accounts, roles nobody has used in months, and toxic combinations like "this identity can read the secrets vault &lt;em&gt;and&lt;/em&gt; is reachable from the internet."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answers:&lt;/strong&gt; "Who can do too much?"&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; Microsoft Entra Permissions Management, Wiz, Sonrai, Tenable (Ermetic)&lt;/p&gt;

&lt;h2&gt;
  
  
  CNAPP — Cloud-Native Application Protection Platform
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The whole smart-home security system.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's the trick: CNAPP is &lt;strong&gt;not a new capability&lt;/strong&gt;. It's the umbrella that bundles CSPM + CWPP + CIEM (plus IaC scanning and container scanning) into one platform with one dashboard. Its real power is &lt;strong&gt;chaining findings into attack paths&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;vulnerable container &lt;strong&gt;+&lt;/strong&gt; public exposure &lt;strong&gt;+&lt;/strong&gt; admin identity &lt;strong&gt;=&lt;/strong&gt; critical attack path, fix this first&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That prioritization — attack paths instead of ten thousand isolated alerts — is why the entire market converged on CNAPP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answers:&lt;/strong&gt; "Show me the actual attack paths across everything."&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; Wiz, Prisma Cloud, Orca, Defender for Cloud (full plans) — the same vendors, selling the full suite&lt;/p&gt;

&lt;h2&gt;
  
  
  EASM — External Attack Surface Management
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Walking around the street outside, checking what a burglar sees.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;EASM flips the camera: it scans your organization &lt;strong&gt;from the public internet&lt;/strong&gt;, like an attacker with a browser and patience. It routinely finds assets you forgot existed — abandoned subdomains, exposed test APIs, expired certificates, shadow IT spun up by a team three years ago.&lt;/p&gt;

&lt;p&gt;The uncomfortable, recurring outcome: EASM discovers things that aren't in your asset inventory at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answers:&lt;/strong&gt; "What can the internet see of mine?"&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; Microsoft Defender EASM, Censys, Palo Alto Cortex Xpanse — and Shodan for the manual version&lt;/p&gt;

&lt;h2&gt;
  
  
  The House, One Last Time
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acronym&lt;/th&gt;
&lt;th&gt;Watches&lt;/th&gt;
&lt;th&gt;House analogy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSPM&lt;/td&gt;
&lt;td&gt;Settings/config&lt;/td&gt;
&lt;td&gt;Doors and windows locked?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWPP&lt;/td&gt;
&lt;td&gt;Workloads&lt;/td&gt;
&lt;td&gt;Intruder inside a room?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CIEM&lt;/td&gt;
&lt;td&gt;Identities/permissions&lt;/td&gt;
&lt;td&gt;Who has too many keys?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNAPP&lt;/td&gt;
&lt;td&gt;All of the above&lt;/td&gt;
&lt;td&gt;The full smart-home system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EASM&lt;/td&gt;
&lt;td&gt;The outside view&lt;/td&gt;
&lt;td&gt;What the burglar sees from the street&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One final classification note: CSPM, CWPP, CIEM, and CNAPP are all &lt;strong&gt;white box&lt;/strong&gt; — they read your subscriptions with granted access. &lt;strong&gt;EASM is the lone black box&lt;/strong&gt; of the group, and that outsider's perspective is exactly what makes it valuable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Series Wrap-Up
&lt;/h2&gt;

&lt;p&gt;Across four posts, the entire landscape reduces to four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is my application code safe?&lt;/strong&gt; → App Security Testing (SAST, DAST, SCA…)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can someone actually break in?&lt;/strong&gt; → Offensive assessments (VAPT, red teams, bug bounties)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are we designing, building, and proving it right?&lt;/strong&gt; → Reviews and audits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my cloud continuously watched?&lt;/strong&gt; → CSPM/CNAPP family&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Master which question each acronym answers, and you'll never nod along blankly in a security meeting again.&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>The Security Work That Never Attacks Anything: Reviews, Audits &amp; Threat Modeling</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:30:00 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/the-security-work-that-never-attacks-anything-reviews-audits-threat-modeling-1pda</link>
      <guid>https://dev.to/vicky_acedia/the-security-work-that-never-attacks-anything-reviews-audits-threat-modeling-1pda</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of 4 in the "Security Testing Landscape" series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The previous two posts covered tools and humans that attack systems. This group is different: &lt;strong&gt;nobody attacks anything&lt;/strong&gt;. These are humans (and a few platforms) inspecting your designs, code, configurations, and paperwork — with full access. Every single item here is white box; that's the group's signature.&lt;/p&gt;

&lt;p&gt;The best way to remember them is by the &lt;strong&gt;question each one answers on the project timeline&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Are we designing it right?" — Before You Build
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Threat Modeling
&lt;/h3&gt;

&lt;p&gt;A structured brainstorm at design time: for each component, how would an attacker abuse it? The output is a prioritized list of threats and mitigations — before a single line of code exists, when fixes are cheapest.&lt;/p&gt;

&lt;p&gt;The frameworks you'll hear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STRIDE&lt;/strong&gt; — the classic threat checklist: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PASTA&lt;/strong&gt; — a risk-centric, seven-stage methodology that ties threats to business impact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DREAD&lt;/strong&gt; — a scoring model for ranking threats: Damage, Reproducibility, Exploitability, Affected users, Discoverability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Microsoft Threat Modeling Tool, OWASP Threat Dragon, IriusRisk&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture / Design Review
&lt;/h3&gt;

&lt;p&gt;Security engineers walk the diagrams and data flows: where are the trust boundaries, is authentication centralized, are secrets in a vault, does a frontend talk directly to a backend when it shouldn't. If you've ever defended an architecture diagram in front of a senior reviewer, you've lived this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt; cloud well-architected security pillars, OWASP ASVS&lt;/p&gt;

&lt;h2&gt;
  
  
  "Did we build it right?" — During and After the Build
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Secure Code Review (Manual)
&lt;/h3&gt;

&lt;p&gt;A human reads the code — especially auth logic, crypto usage, and input handling — catching design-level flaws that automated SAST cannot, like "this check can be bypassed by calling the API endpoints in a different order." Tooling assists (Semgrep, CodeQL, PR review workflows), but the value is the brain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration Review / Hardening Assessment
&lt;/h3&gt;

&lt;p&gt;Compare actual server, database, and cloud settings against a hardened baseline: TLS versions, open ports, default accounts, logging enabled. The gold-standard baselines are the &lt;strong&gt;CIS Benchmarks&lt;/strong&gt; and &lt;strong&gt;DISA STIGs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; CIS-CAT, Nessus compliance scans, Azure Policy&lt;/p&gt;

&lt;h2&gt;
  
  
  "Are we running it right?" — Continuously
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cloud Security Posture Assessment
&lt;/h3&gt;

&lt;p&gt;The configuration review idea, applied to your entire cloud estate, continuously: public storage, over-permissive IAM, missing encryption, exposed endpoints — all scored against benchmarks in near-real time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Microsoft Defender for Cloud (secure score), Prisma Cloud, Wiz, Orca&lt;/p&gt;

&lt;h2&gt;
  
  
  "Can we prove it to outsiders?" — When Auditors Arrive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Security Audit / Compliance Assessment
&lt;/h3&gt;

&lt;p&gt;An external auditor checks whether you &lt;em&gt;actually follow&lt;/em&gt; a standard — through evidence, policies, interviews, and screenshots. The output is a certification or findings report, never an exploit.&lt;/p&gt;

&lt;p&gt;The standards you'll meet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ISO 27001&lt;/strong&gt; — certifies your information security management system&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SOC 2&lt;/strong&gt; — a trust-criteria report, the staple for SaaS vendors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS&lt;/strong&gt; — mandatory if you touch card data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HIPAA / GDPR&lt;/strong&gt; — health data and personal data regulations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; GRC platforms like Vanta, Drata, ServiceNow GRC — but mostly document review.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Are we spending money right?" — Above Everything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Risk Assessment
&lt;/h3&gt;

&lt;p&gt;The business-level exercise that sits on top of all of it: list assets → identify threats → score likelihood × impact → prioritize. This decides where the security budget goes and feeds every other activity on this page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frameworks:&lt;/strong&gt; ISO 27005, NIST 800-30, and &lt;strong&gt;FAIR&lt;/strong&gt; if you want risk quantified in actual currency.&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; honestly, spreadsheets — or a GRC platform if you're fancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline Recap
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Activity&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Designing it right?&lt;/td&gt;
&lt;td&gt;Threat Modeling, Architecture Review&lt;/td&gt;
&lt;td&gt;Before build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built it right?&lt;/td&gt;
&lt;td&gt;Code Review, Config Review&lt;/td&gt;
&lt;td&gt;During/after build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running it right?&lt;/td&gt;
&lt;td&gt;Cloud Posture Assessment&lt;/td&gt;
&lt;td&gt;Continuously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proving it?&lt;/td&gt;
&lt;td&gt;Compliance Audit&lt;/td&gt;
&lt;td&gt;Audit season&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spending right?&lt;/td&gt;
&lt;td&gt;Risk Assessment&lt;/td&gt;
&lt;td&gt;Always, above all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Remember: the testing groups &lt;strong&gt;attack&lt;/strong&gt;; this group &lt;strong&gt;inspects with full access&lt;/strong&gt;. Both are necessary — inspection finds what attacks miss, and attacks prove what inspections suspect.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Final post in the series: the cloud alphabet soup — CSPM, CWPP, CIEM, CNAPP, and EASM.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>From Vulnerability Scans to Red Teams: The Offensive Security Ladder</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:29:14 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/from-vulnerability-scans-to-red-teams-the-offensive-security-ladder-365b</link>
      <guid>https://dev.to/vicky_acedia/from-vulnerability-scans-to-red-teams-the-offensive-security-ladder-365b</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of 4 in the "Security Testing Landscape" series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every company eventually asks: "are we actually hackable?" There's an entire industry of ways to answer that question, and they form a &lt;strong&gt;ladder of realism and cost&lt;/strong&gt;. This post climbs it rung by rung.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 1: VA — Vulnerability Assessment
&lt;/h2&gt;

&lt;p&gt;An automated scanner sweeps your servers, VMs, and network for &lt;strong&gt;known&lt;/strong&gt; CVEs and misconfigurations. It's breadth over depth: it finds and lists problems, but never exploits them. Think of it as a metal detector — it beeps, it doesn't dig.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Nessus, Qualys, OpenVAS, Rapid7 InsightVM&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; Usually grey (the scanner gets network access or credentials); can run uncredentialed as black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 2: PT — Penetration Testing
&lt;/h2&gt;

&lt;p&gt;Now a &lt;strong&gt;human&lt;/strong&gt; takes the scanner's findings and actually exploits them — chaining vulnerabilities, escalating privileges, reaching real data — to prove genuine business impact. Depth over breadth, scoped and time-boxed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Burp Suite, Metasploit, Nmap, SQLMap, BloodHound, Cobalt Strike&lt;/p&gt;

&lt;p&gt;This is where the black/grey/white box terminology originates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Black box:&lt;/strong&gt; the tester gets nothing but a URL or IP — simulates an external attacker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grey box:&lt;/strong&gt; the tester gets a normal user account and basic docs — simulates an insider or a compromised user. This is the most common flavor in practice, and the best value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;White box:&lt;/strong&gt; the tester gets source code, architecture diagrams, and admin credentials — maximum coverage per paid day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rung 3: VAPT — The Compliance Staple
&lt;/h2&gt;

&lt;p&gt;VAPT is simply &lt;strong&gt;VA + PT packaged together&lt;/strong&gt;: scan everything automatically, then let a human manually exploit the interesting bits. This is the standard annual engagement companies buy for compliance, and the term you'll see on every audit checklist in this part of the world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 4: Red Teaming
&lt;/h2&gt;

&lt;p&gt;A red team gets a &lt;strong&gt;goal&lt;/strong&gt;, not a scope: "reach the HR database without being detected." Weeks long, stealthy, no holds barred — phishing, physical entry, custom malware, whatever works. Crucially, it tests your &lt;strong&gt;defenders and detection capability&lt;/strong&gt;, not just your systems. The blue team usually isn't told it's happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Cobalt Strike, Sliver, Mythic, GoPhish&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; Black by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 5: Purple Teaming
&lt;/h2&gt;

&lt;p&gt;Red and blue sit at the &lt;strong&gt;same table&lt;/strong&gt;. Red runs an attack technique, blue immediately checks "did our SIEM catch that?", they tune the detection, and repeat. Collaborative rather than adversarial — the fastest way to actually improve detection coverage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; MITRE ATT&amp;amp;CK framework, Atomic Red Team, VECTR&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; White — full transparency is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 6: BAS — Breach &amp;amp; Attack Simulation
&lt;/h2&gt;

&lt;p&gt;Software that &lt;strong&gt;continuously and automatically&lt;/strong&gt; replays known attack techniques inside your environment to verify your controls (EDR, SIEM, WAF) still detect them — an always-on mini red team that never sleeps or invoices per day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; AttackIQ, SafeBreach, Cymulate, Picus&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; Grey/white — agents installed inside, techniques known.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 7: Bug Bounty
&lt;/h2&gt;

&lt;p&gt;Open the doors and invite &lt;strong&gt;researchers worldwide&lt;/strong&gt; to attack your production app, paying per valid bug instead of per day. Continuous coverage, pay-for-results economics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platforms:&lt;/strong&gt; HackerOne, Bugcrowd, Intigriti&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; Black — researchers get exactly what the public gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human-Focused Rungs
&lt;/h2&gt;

&lt;p&gt;Three assessments attack things other than servers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Social Engineering / Phishing Simulation&lt;/strong&gt; targets your &lt;strong&gt;people&lt;/strong&gt; — fake phishing emails, phone pretexting (vishing), USB drops — measuring click and credential-submission rates. Tools: GoPhish, KnowBe4, Microsoft Attack Simulator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wireless Assessment&lt;/strong&gt; targets your &lt;strong&gt;airwaves&lt;/strong&gt; — rogue access points, WPA2 handshake cracking, evil-twin attacks. Tools: Aircrack-ng, Kismet, WiFi Pineapple.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physical Security Testing&lt;/strong&gt; targets your &lt;strong&gt;building&lt;/strong&gt; — tailgating, badge cloning, lock picking, plugging into exposed ports. Tools: Proxmark, LAN Turtle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are black box: the targets don't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ladder, One Line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;VA&lt;/strong&gt; (scanner lists bugs) → &lt;strong&gt;PT&lt;/strong&gt; (human proves bugs) → &lt;strong&gt;VAPT&lt;/strong&gt; (both — the compliance staple) → &lt;strong&gt;Red Team&lt;/strong&gt; (full attack, tests your people and detection) → &lt;strong&gt;Purple&lt;/strong&gt; (red and blue learn together) → &lt;strong&gt;BAS&lt;/strong&gt; (automate it forever) → &lt;strong&gt;Bug Bounty&lt;/strong&gt; (outsource it to the world).&lt;/p&gt;

&lt;p&gt;Each rung up costs more and simulates reality more faithfully. Most organizations start with VAPT and grow upward.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next in the series: the quiet group that never attacks anything — reviews, audits, and threat modeling.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>The Alphabet Soup of Application Security Testing, Explained With a Car</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:28:48 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/the-alphabet-soup-of-application-security-testing-explained-with-a-car-234k</link>
      <guid>https://dev.to/vicky_acedia/the-alphabet-soup-of-application-security-testing-explained-with-a-car-234k</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of 4 in the "Security Testing Landscape" series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've ever sat in a security review meeting and heard SAST, DAST, IAST, and SCA thrown around in the same sentence, you know the feeling: everyone nods, half the room is quietly Googling. This post fixes that permanently, using one analogy — &lt;strong&gt;your application is a car you're building and driving.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Four
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SAST — Static Application Security Testing
&lt;/h3&gt;

&lt;p&gt;SAST is reading the car's &lt;strong&gt;blueprint&lt;/strong&gt; before it's ever built. You spot that the brakes are drawn wrong without starting an engine.&lt;/p&gt;

&lt;p&gt;In IT terms: SAST scans your &lt;strong&gt;source code&lt;/strong&gt; for insecure patterns — SQL injection, hardcoded credentials, weak crypto — without running the application. It lives in your CI pipeline and fires on every commit or pull request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; SonarQube, Checkmarx, Fortify, Semgrep, GitHub CodeQL&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; White box — it has full access to your code.&lt;/p&gt;

&lt;h3&gt;
  
  
  DAST — Dynamic Application Security Testing
&lt;/h3&gt;

&lt;p&gt;DAST is &lt;strong&gt;test-driving the finished car and deliberately trying to crash it&lt;/strong&gt;. It never looks at the blueprint.&lt;/p&gt;

&lt;p&gt;In IT terms: DAST attacks your &lt;strong&gt;running, deployed application&lt;/strong&gt; over HTTP exactly like an external hacker would — malicious payloads into URLs, forms, and headers. It has zero knowledge of your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; OWASP ZAP, Burp Suite, Acunetix, Invicti&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; Black box — it only sees what an attacker sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  IAST — Interactive Application Security Testing
&lt;/h3&gt;

&lt;p&gt;IAST is a &lt;strong&gt;sensor inside the car during the test drive&lt;/strong&gt;. When the brakes get slammed, it feels exactly which part shook.&lt;/p&gt;

&lt;p&gt;In IT terms: an agent sits inside the running app (in the JVM or .NET runtime) and watches code execute while your QA or DAST tests run. Result: it pinpoints the exact vulnerable line, combining the strengths of SAST and DAST.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Contrast Security, Synopsys Seeker, HCL AppScan&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; Grey box — the app is running, but the agent sees internals.&lt;/p&gt;

&lt;h3&gt;
  
  
  SCA — Software Composition Analysis
&lt;/h3&gt;

&lt;p&gt;Your build might be flawless, but the &lt;strong&gt;tires you bought from another shop have a recall notice&lt;/strong&gt;. SCA checks the parts you didn't make.&lt;/p&gt;

&lt;p&gt;In IT terms: SCA scans your dependency manifests — package.json, pom.xml, .csproj — against CVE databases for vulnerable open-source libraries (think Log4j) and license risks. Given that most modern codebases are 70–90% third-party code, this one is not optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Snyk, Mend, OWASP Dependency-Check, GitHub Dependabot&lt;br&gt;
&lt;strong&gt;Box:&lt;/strong&gt; White box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Supporting Cast
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RASP (Runtime Application Self-Protection)&lt;/strong&gt; is the odd one out — it's not a test at all. It's the &lt;strong&gt;airbag&lt;/strong&gt;: an agent embedded in your production app that blocks real attacks as they happen, like killing a SQL injection mid-execution. Tools: Contrast Protect, Imperva RASP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MAST (Mobile AST)&lt;/strong&gt; is the same testing story for a &lt;strong&gt;motorbike&lt;/strong&gt; — SAST and DAST applied to APK/IPA binaries, hunting mobile-specific issues like insecure local storage and reverse-engineering exposure. Tools: MobSF, NowSecure, Appknox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API Security Testing&lt;/strong&gt; ignores the car body and inspects the &lt;strong&gt;fuel pipe connections&lt;/strong&gt;. Doors can be locked while a leaky pipe sinks you: it tests APIs directly for auth bypass, BOLA/IDOR, and mass assignment, usually driven by the OpenAPI spec. Tools: Postman, Burp Suite, 42Crunch, Salt Security. Typically grey box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fuzzing&lt;/strong&gt; pours &lt;strong&gt;random junk into the fuel tank&lt;/strong&gt; — sand, juice, bolts — until the engine chokes. Massive volumes of malformed input fired at an app or API until it crashes or misbehaves. Tools: AFL++, libFuzzer, Burp Intruder, RESTler. Typically black box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret Scanning&lt;/strong&gt; checks whether someone &lt;strong&gt;left the car keys taped to the windshield&lt;/strong&gt; — leaked API keys, tokens, and connection strings in repos and git history. Tools: GitLeaks, TruffleHog, GitHub Secret Scanning. White box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container Scanning&lt;/strong&gt; inspects the &lt;strong&gt;shipping crate&lt;/strong&gt; the car arrives in — Docker images scanned layer by layer for OS-level CVEs before deployment. Tools: Trivy, Grype, Aqua. White box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IaC Scanning&lt;/strong&gt; audits the &lt;strong&gt;factory setup instructions&lt;/strong&gt;. If the manual says "leave the door open," every car built there is unsafe: Terraform, Bicep, ARM, and Kubernetes YAML scanned for misconfigurations before anything is provisioned. Tools: Checkov, tfsec, Terrascan, KICS. White box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline That Makes It Stick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before the app runs&lt;/strong&gt; (paperwork and parts): SAST, SCA, Secret Scanning, Container Scanning, IaC Scanning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;While it runs in testing&lt;/strong&gt; (test drives): DAST, IAST, Fuzzing, API Testing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;While it runs in production&lt;/strong&gt; (the airbag): RASP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the box rule: anything that reads your &lt;strong&gt;files&lt;/strong&gt; is white box; anything that attacks the &lt;strong&gt;running app from outside&lt;/strong&gt; is black box; the agent that does both is grey.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next in the series: the offensive world — VA, PT, VAPT, red teams and bug bounties.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>The Product Maturity Matrix: How We Measure Whether Our Internal Products Are Actually "Grown Up"</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:07:50 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/the-product-maturity-matrix-how-we-measure-whether-our-internal-products-are-actually-grown-up-2d6l</link>
      <guid>https://dev.to/vicky_acedia/the-product-maturity-matrix-how-we-measure-whether-our-internal-products-are-actually-grown-up-2d6l</guid>
      <description>&lt;p&gt;Every engineering team ships features. Very few teams can answer a harder question: &lt;em&gt;is this product mature?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Not "does it work" — mature. Can it survive an audit? Can it survive the person who built it leaving? Can it survive an AI prompt injection attack at 2 AM on a weekend?&lt;/p&gt;

&lt;p&gt;Recently, I was asked to review and refine our product maturity matrix — the framework we use to assess every internal product across our portfolio. What started as a review exercise turned into a genuinely useful lesson in what "maturity" actually means for modern products, especially ones with AI baked in. Here's what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a maturity matrix at all?
&lt;/h2&gt;

&lt;p&gt;When you're running a portfolio of internal products — a PSA platform, an ATS, an AI assistant, dozens of integrations — you can't rely on gut feel. Each product has different teams, different tech debt, different risk profiles. A maturity matrix gives you one honest lens across all of them.&lt;/p&gt;

&lt;p&gt;The version I reviewed covered 12 areas. By the time I was done, it was 10 — and sharper for it. The final structure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Access Management&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Monitoring&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Encryption &amp;amp; Data Protection&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI Security &amp;amp; Risk Management&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture &amp;amp; Design&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Quality&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integration Management&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DevOps &amp;amp; Engineering Automation&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Engineering Excellence&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each area has five concrete criteria. Not aspirations — checkable statements. "Rollback procedure is defined &lt;strong&gt;and tested&lt;/strong&gt;." "Access reviews are done periodically &lt;strong&gt;with business owners&lt;/strong&gt;." The difference between a checklist and a wishlist is whether you can fail it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: Kill the duplicates, or the matrix eats itself
&lt;/h2&gt;

&lt;p&gt;The first thing I found when reviewing was overlap. "Security attacks" appeared under Observability. "AI red-teaming" appeared in two places. Credential expiry monitoring was floating around with no clear home.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. When the same control lives in three rows, three teams either all claim it or all assume someone else owns it. Both outcomes are bad.&lt;/p&gt;

&lt;p&gt;The fix was a simple ownership rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Observability = visibility.&lt;/strong&gt; Can you &lt;em&gt;see&lt;/em&gt; what's happening — logs, dashboards, traces, latency, failures?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Monitoring = detection and response.&lt;/strong&gt; Attacks, network traffic, alerts, SLAs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Security = assessment.&lt;/strong&gt; Red-teaming, risk reviews, guardrail evaluations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration Management = operational hygiene.&lt;/strong&gt; Job monitoring, credential and token expiry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One control, one home. Everything got easier after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: "100% accuracy" is not a criterion — it's a trap
&lt;/h2&gt;

&lt;p&gt;The original data quality row demanded "100% accuracy of all business reports." Sounds rigorous. It's actually the opposite.&lt;/p&gt;

&lt;p&gt;You cannot measure 100%. You cannot prove it. Every audit against it either fails or gets hand-waved — and hand-waving is precisely what a maturity matrix exists to eliminate.&lt;/p&gt;

&lt;p&gt;We replaced it with something you can actually run:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Business reports are validated periodically by the tech team against source data, and reported data issues are resolved within a defined SLA.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Measurable. Auditable. Honest. If your maturity criteria require perfection, teams will quietly stop taking the whole matrix seriously. Write criteria a good team can pass and a struggling team will visibly fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: AI changed what "mature" means
&lt;/h2&gt;

&lt;p&gt;Five years ago, a maturity matrix stopped at DevOps and monitoring. Today, if your products have AI agents in them — and ours increasingly do — you need two new muscles:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI Security &amp;amp; Risk Management&lt;/strong&gt; as a first-class area. Prompt injection, data leakage, model misuse — these aren't hypotheticals. In our own security assessments we've caught things like PII slipping out through encoded payloads that a naive filter would never flag. Red-teaming your own AI features before someone else does it for you is now table stakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI observability&lt;/strong&gt; inside the observability row. Traditional APM tells you the request took 800ms. It doesn't tell you what the user asked, what the model answered, how many tokens it burned, or where in the agent chain the latency lives. Mature AI products trace the full conversation path: query → layers → response, with token usage and guardrail outcomes visible.&lt;/p&gt;

&lt;p&gt;If your maturity framework predates your AI features, it's assessing a product that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: The most embarrassing finding is the most valuable one
&lt;/h2&gt;

&lt;p&gt;One row in the matrix — Encryption &amp;amp; Data Protection — contained criteria that had clearly been copy-pasted from a completely different section. It described automation workflows. Nothing about encryption at all.&lt;/p&gt;

&lt;p&gt;Nobody had noticed, because nobody had &lt;em&gt;read&lt;/em&gt; it. It had been reviewed in meetings, circulated in emails, and nodded at repeatedly.&lt;/p&gt;

&lt;p&gt;That's the real argument for periodic deep reviews of governance documents: not to add more rows, but to check that the existing ones still say what everyone assumes they say. We rewrote it properly — encryption at rest and in transit, secrets in a managed vault with no hardcoded credentials, PII classification and masking, retention and disposal policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: Merge what belongs together
&lt;/h2&gt;

&lt;p&gt;The original matrix had separate rows for "Coding &amp;amp; Secure Practices" and "Release Notes," plus an empty placeholder called "Engineering Excellence." We merged all three.&lt;/p&gt;

&lt;p&gt;Code review, static analysis, dependency scanning, release notes, updated documentation — these aren't separate disciplines. They're one discipline: &lt;em&gt;the craft of shipping responsibly&lt;/em&gt;. Splitting them across rows just multiplied the paperwork without adding rigor.&lt;/p&gt;

&lt;p&gt;A shorter matrix that people actually use beats a longer one they skim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A product maturity matrix isn't a compliance artifact. Done well, it's a mirror. It tells you which of your products would survive scrutiny and which are running on luck and tribal knowledge.&lt;/p&gt;

&lt;p&gt;Three principles carried the whole review:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every criterion must be checkable.&lt;/strong&gt; If you can't fail it, delete it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every control needs exactly one home.&lt;/strong&gt; Overlap is unowned risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The matrix must evolve with the stack.&lt;/strong&gt; AI features demand AI-shaped criteria.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your team has a maturity framework gathering dust, open it up and actually read every row. You might find automation criteria hiding in your encryption section too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What does product maturity look like in your organization? I'd genuinely like to hear how other teams are handling AI security and observability in their governance frameworks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systems</category>
      <category>product</category>
    </item>
    <item>
      <title>Serving Millions of Users Without Melting the Database (Explained with Swiggy)</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Thu, 20 Aug 2026 04:08:33 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/serving-millions-of-users-without-melting-the-database-explained-with-swiggy-14ka</link>
      <guid>https://dev.to/vicky_acedia/serving-millions-of-users-without-melting-the-database-explained-with-swiggy-14ka</guid>
      <description>&lt;p&gt;At 8:30 PM on a Friday, millions of people open a food app at once. How does it stay fast — and how does the database survive? Not with one trick, but with five layers, each removing load before it reaches the expensive part. Here they are, using Swiggy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 5 techniques
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;One-line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Caching&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Keep hot data close, skip the slow DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Load Balancing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Spread traffic across many servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;CDN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Serve content from near the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Partitioning / Sharding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Split one huge DB into pieces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Autoscaling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Add/remove servers automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. Caching
&lt;/h2&gt;

&lt;p&gt;Keep frequently-used data somewhere fast so you don't hit the slow database every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where caches live:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Swiggy example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Client&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On your phone&lt;/td&gt;
&lt;td&gt;Your cart, cached locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CDN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Edge servers worldwide&lt;/td&gt;
&lt;td&gt;Restaurant images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Distributed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A shared cache cluster&lt;/td&gt;
&lt;td&gt;Menu, sessions (Redis)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;How writes interact with the cache:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache-aside&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;App fills the cache on a miss&lt;/td&gt;
&lt;td&gt;Simple; first read is slow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write-through&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Write to cache + DB together&lt;/td&gt;
&lt;td&gt;Always fresh; writes slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write-back&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Write to cache now, DB later&lt;/td&gt;
&lt;td&gt;Fast writes; risk of loss if cache dies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; menu in a distributed cache (Redis), images on a CDN, cart on the client. Different data, different cache home.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Azure Cache for Redis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The golden rule:&lt;/strong&gt; cache things where "a few seconds stale is fine" (menu, restaurant list). Never cache things where stale = wrong (wallet balance, payment status).&lt;/p&gt;


&lt;h2&gt;
  
  
  2. Load Balancing
&lt;/h2&gt;

&lt;p&gt;Spread incoming traffic across many identical servers so no single one drowns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              ┌→ Server 1
Users → [ LB ]┼→ Server 2
              └→ Server 3   (LB picks which one)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two types — L4 vs L7:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;L4 (Transport)&lt;/th&gt;
&lt;th&gt;L7 (Application)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sees&lt;/td&gt;
&lt;td&gt;IP + port only&lt;/td&gt;
&lt;td&gt;Full HTTP (URL, headers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Faster, dumber&lt;/td&gt;
&lt;td&gt;Smarter, slightly slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can do&lt;/td&gt;
&lt;td&gt;Raw distribution&lt;/td&gt;
&lt;td&gt;Route &lt;code&gt;/search&lt;/code&gt; vs &lt;code&gt;/pay&lt;/code&gt; to different pools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;How it picks a server:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Round-robin&lt;/td&gt;
&lt;td&gt;Next server in turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Least connections&lt;/td&gt;
&lt;td&gt;The least-busy server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP hash&lt;/td&gt;
&lt;td&gt;Same user → same server (sticky)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; millions of requests spread across hundreds of servers. An L7 balancer routes &lt;code&gt;/search&lt;/code&gt; to search servers and &lt;code&gt;/payment&lt;/code&gt; to payment servers.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Load Balancer (L4), Application Gateway / Front Door (L7).&lt;/p&gt;


&lt;h2&gt;
  
  
  3. CDN — Content Delivery Network
&lt;/h2&gt;

&lt;p&gt;Copy static content to servers around the world, so users get it from &lt;em&gt;nearby&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ No CDN:  user in Chennai → fetches image from a US server (slow, far)
✅ CDN:     user in Chennai → fetches from a Chennai edge server (fast, near)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How content actually gets there — it's pull-based, not push:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First user in a region requests &lt;code&gt;logo.png&lt;/code&gt; → the edge has nothing → it &lt;strong&gt;fetches from your origin&lt;/strong&gt;, stores a copy, serves it.&lt;/li&gt;
&lt;li&gt;Next users in that region → served from the &lt;strong&gt;edge copy&lt;/strong&gt;; origin untouched.&lt;/li&gt;
&lt;li&gt;Each edge manages its own cache, so another region still pulls from origin until &lt;em&gt;its&lt;/em&gt; edge caches it too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You never "upload" files to a CDN. The &lt;strong&gt;first request in each region fills the cache&lt;/strong&gt; — which is why it needs almost no setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three things beginners get wrong:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Myth&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"The CDN caches automatically"&lt;/td&gt;
&lt;td&gt;On modern services (like Azure Front Door) caching is &lt;strong&gt;opt-in per route&lt;/strong&gt; — you switch it on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"It's safe to cache everything"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Never cache authenticated or per-user pages&lt;/strong&gt; — a shared edge cache can serve one user's data to another&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Staleness is controlled"&lt;/td&gt;
&lt;td&gt;If your origin sends no &lt;code&gt;Cache-Control&lt;/code&gt;, the default TTL can be &lt;strong&gt;days&lt;/strong&gt; — set your own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; restaurant photos, app JS/CSS, menu images — all static, all cached at the nearest edge. Your biryani photo loads instantly because it's cached 20km away, not 12,000km. But the &lt;em&gt;logged-in order page&lt;/em&gt; is never cached — that stays dynamic.&lt;br&gt;
&lt;strong&gt;Key:&lt;/strong&gt; a CDN is caching for &lt;em&gt;location&lt;/em&gt;. Distance = latency, so serve from close by.&lt;/p&gt;


&lt;h2&gt;
  
  
  4. Partitioning / Sharding
&lt;/h2&gt;

&lt;p&gt;Split one giant database into smaller pieces (shards), each holding part of the data.&lt;/p&gt;

&lt;p&gt;The make-or-break decision is the &lt;strong&gt;partition key&lt;/strong&gt; — &lt;em&gt;how&lt;/em&gt; you split.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Watch out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hash(key)&lt;/code&gt; decides the shard&lt;/td&gt;
&lt;td&gt;Even spread, but no range queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Range&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A–M here, N–Z there&lt;/td&gt;
&lt;td&gt;Simple, but can create hotspots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Directory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A lookup table maps key → shard&lt;/td&gt;
&lt;td&gt;Flexible, but the lookup is a dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scales beyond one machine&lt;/td&gt;
&lt;td&gt;Cross-shard queries are painful&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Each shard is smaller/faster&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The partition key is ~irreversible&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; orders partitioned by &lt;code&gt;cityId&lt;/code&gt; or &lt;code&gt;userId&lt;/code&gt; — Bengaluru's orders on one shard, Delhi's on another.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; this is exactly the &lt;strong&gt;Cosmos DB partition key&lt;/strong&gt; — the single most important, effectively permanent design choice you make up front.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;The trap:&lt;/strong&gt; a bad key creates a &lt;strong&gt;hot partition&lt;/strong&gt; — one shard takes all the traffic while the others sit idle. Choose a key that spreads load &lt;em&gt;evenly&lt;/em&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  5. Autoscaling
&lt;/h2&gt;

&lt;p&gt;Automatically add servers when busy, remove them when quiet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 PM (quiet):   ▪▪         (3 servers)
8:30 PM (peak): ▪▪▪▪▪▪▪▪▪▪ (auto-scaled to 20)
2 AM (dead):    ▪          (scaled back to 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reactive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scale when CPU/queue crosses a threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Predictive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scale &lt;em&gt;ahead&lt;/em&gt; of a known pattern (the dinner rush)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pay only for what you use&lt;/td&gt;
&lt;td&gt;Scaling isn't instant (there's lag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handles spikes automatically&lt;/td&gt;
&lt;td&gt;Cold starts on new instances&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; scale up before the 8 PM rush, scale down after midnight. Never pay for 500 servers at 3 AM.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; VM Scale Sets, AKS autoscaler, App Service autoscale.&lt;/p&gt;




&lt;h2&gt;
  
  
  How they connect
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CDN            → serves static content from the edge (never hits your servers)
Load Balancer  → spreads the dynamic traffic that remains
Caching        → cuts DB reads for that traffic
Partitioning   → splits the DB when caching isn't enough
Autoscaling    → adjusts server count to match live demand
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer removes load before it reaches the expensive part: &lt;strong&gt;CDN catches the easy stuff → the load balancer spreads the rest → the cache skips the DB → partitioning shares the DB load → autoscaling flexes capacity.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The whole thing in one line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Scaling isn't one big trick — it's &lt;strong&gt;five layers peeling load away&lt;/strong&gt; before it reaches the database. Serve static from the edge, spread the rest, cache what you can, split what you can't, and flex capacity to match the crowd.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>design</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>How Services Send Messages Without Losing Them (Explained with Swiggy)</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Wed, 19 Aug 2026 04:05:51 +0000</pubDate>
      <link>https://dev.to/vicky_acedia/how-services-send-messages-without-losing-them-explained-with-swiggy-58p8</link>
      <guid>https://dev.to/vicky_acedia/how-services-send-messages-without-losing-them-explained-with-swiggy-58p8</guid>
      <description>&lt;p&gt;Instead of calling each other directly, services often drop &lt;em&gt;messages&lt;/em&gt; and move on. It's more resilient — but it raises real questions. What if the receiver is down? What if a message gets processed twice and a customer is charged double? These 5 patterns are the answers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 5 patterns
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;One-line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Message Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A buffer between sender and receiver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Publish-Subscribe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One message, many independent receivers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Competing Consumers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Many workers share one queue to scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dead-Letter Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where failing messages go to be inspected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Idempotent Consumer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Processing twice = same result as once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. Message Queue
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A buffer that holds messages between sender and receiver. One message → one consumer.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order svc → [ ▪▪▪ QUEUE ▪▪▪ ] → Payment svc
            (holds messages safely)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sender &amp;amp; receiver decoupled&lt;/td&gt;
&lt;td&gt;Adds a component to manage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receiver can be down — the message waits&lt;/td&gt;
&lt;td&gt;Not instant (it's async)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Absorbs traffic spikes&lt;/td&gt;
&lt;td&gt;Ordering can be tricky&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; "charge this order" is dropped on a queue. If Payment is briefly down, the message waits — nothing is lost.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Service Bus &lt;strong&gt;Queue&lt;/strong&gt;.&lt;br&gt;
&lt;strong&gt;Key idea:&lt;/strong&gt; each message is consumed &lt;strong&gt;once&lt;/strong&gt;, by &lt;strong&gt;one&lt;/strong&gt; worker.&lt;/p&gt;


&lt;h2&gt;
  
  
  2. Publish-Subscribe (Pub-Sub)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One message published → delivered to MANY subscribers, each independently.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The difference from a queue: a queue delivers to &lt;em&gt;one&lt;/em&gt;. Pub-sub delivers a &lt;em&gt;copy to everyone&lt;/em&gt; who subscribed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Order placed" → [ TOPIC ] →→ Payment    (own copy)
                            →→ Restaurant (own copy)
                            →→ Analytics  (own copy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add new subscribers without touching the sender&lt;/td&gt;
&lt;td&gt;Harder to trace who got what&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fully decoupled&lt;/td&gt;
&lt;td&gt;Eventual consistency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One event, many reactions&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; "order placed" → payment, restaurant, and analytics each get their own copy and react independently.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Service Bus &lt;strong&gt;Topic&lt;/strong&gt; (with subscriptions), or Event Grid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue vs Topic — lock this in:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Queue&lt;/th&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One message → one consumer&lt;/td&gt;
&lt;td&gt;One message → many subscribers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Assign this task"&lt;/td&gt;
&lt;td&gt;"Announce this happened"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;h2&gt;
  
  
  3. Competing Consumers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multiple workers pull from the SAME queue to process faster. They compete for messages.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;             ┌→ Worker 1
[ QUEUE ] ───┼→ Worker 2   (whoever's free grabs the next message)
             └→ Worker 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scale by adding workers&lt;/td&gt;
&lt;td&gt;Message order isn't guaranteed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto load-balances&lt;/td&gt;
&lt;td&gt;Workers must be stateless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drains faster at peak&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; at dinner peak, 50,000 "charge order" messages pile up. Run 100 payment workers off one queue → it drains fast. Each message still goes to exactly one worker (no double-processing).&lt;br&gt;
&lt;strong&gt;This is how you scale a queue consumer.&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  4. Dead-Letter Queue (DLQ)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A separate queue for messages that keep failing — so one bad message can't block everything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The problem it solves is a &lt;strong&gt;poison message&lt;/strong&gt;: one malformed message that fails, retries, fails, retries… forever, jamming the queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ QUEUE ] → try process → fail → retry → fail (3x) → move to [ DLQ ]
                                                       ↑ inspect later; queue keeps flowing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One bad message can't block the line&lt;/td&gt;
&lt;td&gt;Needs monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed messages saved for inspection&lt;/td&gt;
&lt;td&gt;Manual cleanup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; a corrupt order message fails 3 times → it's shoved into the DLQ → the main queue keeps flowing for everyone else. An engineer inspects the DLQ later.&lt;br&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Service Bus has a DLQ &lt;strong&gt;built-in&lt;/strong&gt; on every queue and subscription.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;The trap:&lt;/strong&gt; teams set up a DLQ and never watch it. Messages die silently. Always alert on DLQ depth.&lt;/p&gt;


&lt;h2&gt;
  
  
  5. Idempotent Consumer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Processing the same message twice produces the SAME result as processing it once.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why you need it: queues can deliver a message &lt;strong&gt;twice&lt;/strong&gt; (a network hiccup, a retry). Without protection, "charge ₹450" runs twice → the customer is charged ₹900. 💥&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message "charge order #123" arrives twice:
  ❌ Not idempotent: charge ₹450 + charge ₹450 = ₹900 (disaster)
  ✅ Idempotent:     "already processed #123?" → skip the 2nd → ₹450 ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it works: track a &lt;strong&gt;unique message/operation ID&lt;/strong&gt;; if you've seen it, skip it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;✅ Good&lt;/th&gt;
&lt;th&gt;❌ Bad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Safe against duplicate delivery&lt;/td&gt;
&lt;td&gt;Must store processed IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retries become harmless&lt;/td&gt;
&lt;td&gt;Adds a check on every message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swiggy:&lt;/strong&gt; every payment carries an idempotency key. A duplicate "charge" message is detected and skipped. Charged once.&lt;br&gt;
&lt;strong&gt;This is non-negotiable in money flows&lt;/strong&gt; — "at-least-once delivery" means duplicates &lt;em&gt;will&lt;/em&gt; happen, so consumers &lt;em&gt;must&lt;/em&gt; be idempotent.&lt;/p&gt;




&lt;h2&gt;
  
  
  How it all connects
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message Queue        → the basic buffer (1 → 1)
Publish-Subscribe    → the broadcast version (1 → many)
Competing Consumers  → scale the consumer side (many workers, 1 queue)
Dead-Letter Queue    → catch the failures
Idempotent Consumer  → survive duplicate deliveries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The whole thing in one line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A queue assigns one task; a topic announces to all.&lt;/strong&gt; Then you scale it (competing consumers), protect it (dead-letter queue), and make it safe to retry (idempotent consumer). Get those right and messaging becomes the most reliable way for services to talk.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>microservices</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
