<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>luke.geek.nz Blog</title>
        <link>https://luke.geek.nz/</link>
        <description>luke.geek.nz Blog</description>
        <lastBuildDate>Mon, 31 Aug 2026 03:50:27 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Copyright © 2026 luke.geek.nz.</copyright>
        <item>
            <title><![CDATA[Agent Package Manager for AI Azure Engineering]]></title>
            <link>https://luke.geek.nz/azure/agent-package-manager/</link>
            <guid>https://luke.geek.nz/azure/agent-package-manager/</guid>
            <pubDate>Mon, 31 Aug 2026 03:50:27 GMT</pubDate>
            <description><![CDATA[Agent Package Manager (APM) brings package.json-style manifests, lockfiles, and policy-based governance for AI agents in Azure engineering.]]></description>
            <content:encoded><![CDATA[<p>AI coding tools are getting better quickly. The setup around them is still too often a pile of local files, copied prompts, one-off MCP servers, and tribal knowledge.</p>
<p>One developer has custom GitHub Copilot instructions. Another has Claude agents. Someone else has Cursor rules. The platform team wants approved Azure patterns, Microsoft Learn grounding, cost guidance, security checks, and repeatable onboarding.</p>
<p>Six months later, the audit question is simple and uncomfortable:</p>
<blockquote>
<p>What agent context was installed, where did it come from, and who allowed it?</p>
</blockquote>
<p><a href="https://microsoft.github.io/apm/" target="_blank" rel="noopener noreferrer" class="">Agent Package Manager</a>, or APM, addresses that gap.</p>
<p>APM is a dependency manager for AI agents. The official project describes the core model as declaring the skills, prompts, instructions, plugins, and MCP servers a project needs in one <code>apm.yml</code>, then running <code>apm install</code> to deploy the supported primitives to each agent harness. It also positions the lockfile as the reproducibility artifact, with exact versions and content hashes, and <code>apm-policy.yml</code> as the install-time governance control for dependencies, MCP servers, and targets.</p>
<p>Agent configuration needs this kind of mundane, reviewable plumbing.</p>
<p><img decoding="async" loading="lazy" alt="APM homepage screenshot" src="https://luke.geek.nz/assets/images/apm-homepage-d6e8b84fbf2b11cda00401e101621b88.png" width="1689" height="1096" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-matters-for-azure-teams">Why this matters for Azure teams<a href="https://luke.geek.nz/azure/agent-package-manager/#why-this-matters-for-azure-teams" class="hash-link" aria-label="Direct link to Why this matters for Azure teams" title="Direct link to Why this matters for Azure teams" translate="no">​</a></h2>
<p>Azure engineering work rarely happens in one tool.</p>
<p>A realistic cloud engineering repo might use:</p>
<ul>
<li class="">GitHub Copilot for day-to-day implementation.</li>
<li class="">Claude Code for deep repo work.</li>
<li class="">Cursor or OpenCode for local workflows.</li>
<li class=""><a href="https://learn.microsoft.com/training/support/mcp?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft Learn MCP</a> for current Azure documentation.</li>
<li class=""><a href="https://learn.microsoft.com/azure/developer/azure-mcp-server/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure MCP</a> for cloud resource inspection and operations.</li>
<li class=""><a href="https://learn.microsoft.com/azure/foundry/mcp/get-started?tabs=user&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Foundry MCP</a> for Microsoft Foundry work.</li>
<li class=""><a href="https://context7.com/" target="_blank" rel="noopener noreferrer" class="">Context7</a> for current SDK and framework documentation.</li>
<li class="">Team-specific skills for <a href="https://learn.microsoft.com/azure/aks/what-is-aks?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Kubernetes Service</a>, <a href="https://learn.microsoft.com/azure/app-service/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">App Service</a>, <a href="https://learn.microsoft.com/azure/azure-functions/functions-overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Functions</a>, <a href="https://learn.microsoft.com/azure/api-management/api-management-key-concepts?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure API Management</a>, identity, monitoring, reliability, and cost.</li>
</ul>
<p>In my own <code>Coding</code> repo, the APM manifest now declares the active agent targets explicitly:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">targets</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> copilot</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> claude</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> cursor</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> opencode</span><br></div></code></pre></div></div>
<p>It also declares local primitives and reusable agent-skill packages:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">dependencies</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">apm</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> DietrichGebert/ponytail</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/skills/.github/plugins/azure</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">sdk</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">python</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/skills/.github/plugins/azure</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">sdk</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">dotnet</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/skills/.github/plugins/azure</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">sdk</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">typescript</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/skills/.github/plugins/azure</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">sdk</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">rust</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/skills/.github/plugins/microsoft</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">foundry</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"microsoft/hve-core#c7ee5b9642ab1877a6b5ce5336ebc448b89b2708"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> dotnet/skills</span><br></div></code></pre></div></div>
<p>The workflow is familiar: declare dependencies, lock the result, and validate it.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mcp-servers-are-dependencies-too">MCP servers are dependencies too<a href="https://luke.geek.nz/azure/agent-package-manager/#mcp-servers-are-dependencies-too" class="hash-link" aria-label="Direct link to MCP servers are dependencies too" title="Direct link to MCP servers are dependencies too" translate="no">​</a></h2>
<p>The part I like most is that MCP servers sit in the same dependency story.</p>
<p>That matters because MCP is a tool boundary. If an agent gets access to a server, it gets access to whatever that server exposes. For Azure work, that is powerful and useful, but it should never be invisible.</p>
<p>The repo manifest declares the MCP surface. These are representative entries; the complete manifest is linked in the references.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">dependencies</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">mcp</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> microsoft.learn.mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">registry</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean important">false</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">transport</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> http</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">url</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">//learn.microsoft.com/api/mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> context7</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">registry</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean important">false</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">transport</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> http</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">url</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">//mcp.context7.com/mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> azure</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">registry</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean important">false</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">transport</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> stdio</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">command</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> npx</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">args</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">y</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'@azure/mcp@latest'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> server</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> start</span><br></div></code></pre></div></div>
<p>This moves MCP from “whatever is configured on my machine” into the repo contract.</p>
<p>The manifest declares the active targets and approved MCP servers:</p>
<p><img decoding="async" loading="lazy" alt="APM-declared targets and approved MCP servers" src="https://luke.geek.nz/assets/images/apm-governance-native-533ab095b8a419a49e1e66bf23c7fd11.gif" width="1280" height="720" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="governance-without-ceremony">Governance without ceremony<a href="https://luke.geek.nz/azure/agent-package-manager/#governance-without-ceremony" class="hash-link" aria-label="Direct link to Governance without ceremony" title="Direct link to Governance without ceremony" translate="no">​</a></h2>
<p>I would not start with a giant enterprise policy.</p>
<p>The smallest useful version is an allowlist:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Hypervelocity APM baseline</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">version</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> 1.0.0</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">enforcement</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> block</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">dependencies</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">allow</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> DietrichGebert/ponytail</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> dotnet/skills</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/hve</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">core</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/skills/</span><span class="token important">**</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">mcp</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">allow</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> azure</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> context7</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> foundry</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> iseplaybook</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft.learn.mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">docs</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft.mrc.mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">transport</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">allow</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> http</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> sse</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> stdio</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">self_defined</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> allow</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">compilation</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">target</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">allow</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> claude</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> copilot</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> cursor</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> opencode</span><br></div></code></pre></div></div>
<p>The policy allowlists dependency sources, MCP servers and transports, agent targets, and required manifest metadata. It is install-time governance, not a runtime sandbox: it enforces declarations before files are written, while runtime behaviour remains with the agent harness and exposed tools.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="from-one-repository-to-an-organisation">From one repository to an organisation<a href="https://luke.geek.nz/azure/agent-package-manager/#from-one-repository-to-an-organisation" class="hash-link" aria-label="Direct link to From one repository to an organisation" title="Direct link to From one repository to an organisation" translate="no">​</a></h2>
<p>The interesting part of APM is not just what happens in one repository.</p>
<p>It is what happens when ten, fifty, or five hundred repositories need the same agent engineering baseline.</p>
<p>An organisation might have separate platform, data, security, application, and AI engineering teams. They should not all have to independently discover the same skills, MCP servers, policies, and agent configuration.</p>
<p>The model can instead be layered:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">                         GitHub Organisation</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                       Organisation policy</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                ┌───────────────┼───────────────┐</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                │               │               │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">             Platform          Data          Security</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">               team            team             team</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                │               │               │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          shared skills    shared skills    shared skills</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                │               │               │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                └───────────────┼───────────────┘</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                         Team repositories</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                    ┌───────────┼───────────┐</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                    │           │           │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                  Repo A      Repo B      Repo C</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                    │           │           │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                  apm.yml     apm.yml     apm.yml</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                    │           │           │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                    └───────────┼───────────┘</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                               APM</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                       Agent-specific output</span><br></div></code></pre></div></div>
<p>The important distinction is that teams do not need identical agent configurations.</p>
<p>They need a <strong>common starting point</strong>.</p>
<p>A platform team might provide Azure architecture, AKS, networking, monitoring, reliability, and cost guidance. A security team might provide security and identity guidance. An AI engineering team might provide Foundry and Agent Framework skills.</p>
<p>Individual application teams can then add their own domain-specific context.</p>
<p>That gives the organisation a useful division of responsibility:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Organisation</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    └── What is allowed and required?</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Team</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    └── What capabilities does this team need?</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Repository</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    └── What does this workload specifically require?</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">APM</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    └── Resolve, lock, install, and audit the result.</span><br></div></code></pre></div></div>
<p>This is where APM starts to look less like a developer convenience and more like an organisational distribution mechanism for agent context.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="github-makes-this-practical">GitHub makes this practical<a href="https://luke.geek.nz/azure/agent-package-manager/#github-makes-this-practical" class="hash-link" aria-label="Direct link to GitHub makes this practical" title="Direct link to GitHub makes this practical" translate="no">​</a></h3>
<p>For organisations using GitHub, APM can work alongside the existing repository and organisation model rather than creating another management hierarchy.</p>
<p>The organisation can maintain an APM policy in its GitHub organisation configuration. APM can discover that organisation-level policy from the repository's Git remote, allowing the same baseline to apply across participating repositories.</p>
<p>For example, the organisation could establish rules around:</p>
<ul>
<li class="">approved package sources</li>
<li class="">approved MCP servers</li>
<li class="">approved transports</li>
<li class="">allowed agent targets</li>
<li class="">required packages</li>
<li class="">required manifest metadata</li>
</ul>
<p>Teams still maintain their own <code>apm.yml</code>.</p>
<p>That means the platform team does not need to edit every application repository whenever the organisational policy changes.</p>
<p>The relationship is closer to:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">GitHub Organisation</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        ├── Organisation APM policy</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        ├── Platform packages</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        └── Team repositories</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                 │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                 ├── apm.yml</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                 ├── apm.lock.yaml</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                 └── application code</span><br></div></code></pre></div></div>
<p>GitHub then provides the repository governance around that model.</p>
<p>Organisation rulesets can target multiple repositories and can require workflows or status checks before changes are merged. This provides a natural enforcement point for an APM audit workflow.</p>
<p>The result could look like:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Pull Request</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">GitHub Actions</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ├── apm audit --ci</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ├── dependency checks</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     └── other engineering validation</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">GitHub Ruleset</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Required checks pass</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Merge</span><br></div></code></pre></div></div>
<p>This is important because APM policy is an <strong>install-time and CI governance mechanism</strong>, not a replacement for runtime security or agent sandboxing. Runtime permissions still belong to the agent harness and the underlying platform. <a href="https://microsoft.github.io/apm/enterprise/apm-policy-getting-started/" target="_blank" rel="noopener noreferrer" class="">APM policy documentation</a></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-platform-team-becomes-a-paved-road">The platform team becomes a paved road<a href="https://luke.geek.nz/azure/agent-package-manager/#the-platform-team-becomes-a-paved-road" class="hash-link" aria-label="Direct link to The platform team becomes a paved road" title="Direct link to The platform team becomes a paved road" translate="no">​</a></h3>
<p>This changes the role of a central platform team.</p>
<p>It does not need to become the team that configures every developer's agent.</p>
<p>Instead, it can provide a supported baseline.</p>
<p>For example:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">HVE Azure Engineering Baseline</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Azure architecture</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Azure Well-Architected</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── AKS</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── App Service</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Functions</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── API Management</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Container Apps</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Identity</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Monitoring</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Reliability</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Cost</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">└── Approved MCP servers</span><br></div></code></pre></div></div>
<p>An application team might then consume that baseline and add:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Application Team</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── HVE Azure Engineering Baseline</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Payments domain skills</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Application-specific instructions</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">└── Workload-specific MCP dependencies</span><br></div></code></pre></div></div>
<p>Another team might consume the same baseline but add:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Data Team</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── HVE Azure Engineering Baseline</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Fabric skills</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├── Data engineering guidance</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">└── Data-specific MCP dependencies</span><br></div></code></pre></div></div>
<p>The organisation gets consistency without requiring every team to become identical.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="scaling-the-rollout">Scaling the rollout<a href="https://luke.geek.nz/azure/agent-package-manager/#scaling-the-rollout" class="hash-link" aria-label="Direct link to Scaling the rollout" title="Direct link to Scaling the rollout" translate="no">​</a></h3>
<p>I would also avoid trying to deploy this everywhere on day one.</p>
<p>The rollout can be incremental:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Pilot</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">One platform team</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Several application teams</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Organisation baseline</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Required CI checks</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Fleet-wide adoption</span><br></div></code></pre></div></div>
<p>Start with a small set of capabilities that the platform team can actually support.</p>
<p>Pin reviewed versions.</p>
<p>Run APM audits in CI.</p>
<p>Use GitHub rulesets to make the checks required where appropriate.</p>
<p>Then expand the baseline as the organisation gains confidence.</p>
<p>That is a much more realistic approach than attempting to define every possible agent policy before anyone has used it.</p>
<p>The goal is not to centrally control every prompt an engineer writes.</p>
<p>The goal is to make the <strong>supported path easier than building an agent environment from scratch</strong>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="lockfiles-give-you-the-forensic-answer">Lockfiles give you the forensic answer<a href="https://luke.geek.nz/azure/agent-package-manager/#lockfiles-give-you-the-forensic-answer" class="hash-link" aria-label="Direct link to Lockfiles give you the forensic answer" title="Direct link to Lockfiles give you the forensic answer" translate="no">​</a></h2>
<p>The lockfile is where APM starts to feel familiar.</p>
<p>In this repo, <code>apm.lock.yaml</code> records the CLI version, generation time, resolved commits, deployed files, and content hashes. For example:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">lockfile_version</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'1'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">generated_at</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'2026-08-30T20:20:30.928873+00:00'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">apm_version</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> 0.20.0</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">dependencies</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">repo_url</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> DietrichGebert/ponytail</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">host</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> github.com</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">resolved_commit</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> 2ed6c52c9d7e5e56942508591085fd45dea277d3</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">package_type</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> marketplace_plugin</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">content_hash</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> sha256</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">595855eee726cb344087c38d7a3c29586ce8a0dd9a2e7ad8e8eedc91c8d8c575</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">repo_url</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> dotnet/skills</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">host</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> github.com</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">resolved_commit</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> d68dd70857076a17d4b418649bbcd20a315d59c3</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">package_type</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> marketplace_plugin</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">content_hash</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> sha256</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">34013ed32b6592ccd762f59215480fa5df94b82d0443b7f5288291ac786978e8</span><br></div></code></pre></div></div>
<p>That answers “what was active?” more reliably than a screenshot of local settings.</p>
<p>The lockfile records resolved commits and content hashes for review:</p>
<p><img decoding="async" loading="lazy" alt="APM lockfile with resolved package commits and content hashes" src="https://luke.geek.nz/assets/images/apm-lockfile-native-518edb1e200b54c4671dbca41aa42efc.gif" width="1280" height="720" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="evidence-from-the-repo">Evidence from the repo<a href="https://luke.geek.nz/azure/agent-package-manager/#evidence-from-the-repo" class="hash-link" aria-label="Direct link to Evidence from the repo" title="Direct link to Evidence from the repo" translate="no">​</a></h2>
<p>I validated the current setup with APM CLI <code>0.20.0</code>.</p>
<p><img decoding="async" loading="lazy" alt="APM running in a native Windows Terminal session" src="https://luke.geek.nz/assets/images/apm-native-terminal-9bc862f671ee57f4e38706ea4da1cdbb.gif" width="1280" height="720" class="img_ev3q"></p>
<p>The useful result:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[*] All primitives validated successfully!</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">[i] Validated 86 primitives:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">[i]   * 11 chatmodes</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">[i]   * 75 instructions</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">[i]   * 0 contexts</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">[i]   * 7 MCP dependencies</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">[*] All 27 check(s) passed</span><br></div></code></pre></div></div>
<p>The command used:</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm </span><span class="token operator">--</span><span class="token plain">version</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm compile </span><span class="token operator">--</span><span class="token plain">validate</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm audit </span><span class="token operator">--</span><span class="token plain">ci </span><span class="token operator">--</span><span class="token plain">policy </span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">\apm-policy</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">yml </span><span class="token operator">--</span><span class="token plain">no-drift </span><span class="token operator">--</span><span class="token plain">no-fail-fast</span><br></div></code></pre></div></div>
<p>The <code>--no-drift</code> flag is worth calling out. I used it because this working tree has generated-output drift unrelated to the policy example. That means the evidence proves manifest, lockfile, MCP config, and policy compliance. It does not claim the generated deployed files are fully drift-clean.</p>
<p>That distinction is important. APM can be used in stages:</p>
<ol>
<li class="">Get the manifest right.</li>
<li class="">Generate and commit the lockfile.</li>
<li class="">Add a small policy.</li>
<li class="">Pass policy checks in CI.</li>
<li class="">Tighten drift enforcement once generated files are clean and reproducible.</li>
</ol>
<p>This staged rollout avoids making a giant governance programme a prerequisite.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-hve-core-is">What HVE Core is<a href="https://luke.geek.nz/azure/agent-package-manager/#what-hve-core-is" class="hash-link" aria-label="Direct link to What HVE Core is" title="Direct link to What HVE Core is" translate="no">​</a></h2>
<p><a href="https://github.com/microsoft/hve-core" target="_blank" rel="noopener noreferrer" class="">Hypervelocity Engineering (HVE) Core</a> is Microsoft's open-source library of agentic SDLC workflow building blocks for GitHub Copilot. It brings together specialised agents for research, planning, implementation, and review; reusable prompts; coding instructions; and skills.</p>
<p>The value is not a magic workflow. It is a consistent set of starting points that teams can review, adapt, and use repeatedly. HVE Core is explicitly opinionated and rapidly evolving, so treat it as a source of patterns to evaluate rather than an unexamined platform dependency.</p>
<p>It also makes HVE Core a useful APM example. Instead of copying external skills, agents, prompts, and instructions into a repo, APM declares the upstream package, applies the same policy controls, and locks the exact content alongside the team's local context.</p>
<p>The following is a complete example for someone new to HVE Core: declare the exact source, allow it in policy, install it, and commit the resulting lockfile. The installed content lands in the shared <code>.agents/skills</code> directory, which supported agent harnesses can use.</p>
<p>The quoted reference is deliberate: YAML would otherwise treat <code>#</code> and the commit as a comment.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockTitle_OeMC">apm.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">dependencies</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">apm</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"microsoft/hve-core#c7ee5b9642ab1877a6b5ce5336ebc448b89b2708"</span><br></div></code></pre></div></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockTitle_OeMC">apm-policy.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">dependencies</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">allow</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> microsoft/hve</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">core</span><br></div></code></pre></div></div>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockTitle_OeMC">Install and verify</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm install </span><span class="token operator">--</span><span class="token plain">target agent-skills</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm audit </span><span class="token operator">--</span><span class="token plain">ci </span><span class="token operator">--</span><span class="token plain">policy </span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">\apm-policy</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">yml</span><br></div></code></pre></div></div>
<p>This recorded run uses that setup in an isolated <code>agent-skills</code> target. APM resolves the exact commit, integrates 74 HVE Core skills, and records the commit and content hash in its lockfile:</p>
<p><img decoding="async" loading="lazy" alt="APM installing the pinned HVE Core plugin, integrating 74 skills, and recording its lockfile entry" src="https://luke.geek.nz/assets/images/apm-hve-core-native-6556e66786827d34fd763f24f9aae084.gif" width="1280" height="720" class="img_ev3q"></p>
<p>For a team rollout, start with the subset you can support. Pin a reviewed revision, assess the agents and skills you select, and update deliberately.</p>
<p>For an Azure platform team, I would keep the first implementation small:</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm init</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm install</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm lock</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apm audit </span><span class="token operator">--</span><span class="token plain">ci </span><span class="token operator">--</span><span class="token plain">policy </span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">\apm-policy</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">yml</span><br></div></code></pre></div></div>
<p>Then add a required CI check:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">uses</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> actions/checkout@v4</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">uses</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> microsoft/apm</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">action@v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">run</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> apm audit </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">ci </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">no</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">cache</span><br></div></code></pre></div></div>
<p>The central platform package would carry approved guidance:</p>
<ul>
<li class="">Azure Well-Architected review prompts.</li>
<li class="">AKS architecture and production-readiness skills.</li>
<li class="">Azure Functions, App Service, APIM, Container Apps, and IaC skills.</li>
<li class="">Azure AI Foundry and Agent Framework skills.</li>
<li class="">Security, identity, monitoring, reliability, and cost instructions.</li>
</ul>
<p>Application repos would keep local context for their own architecture and domain.</p>
<p>The policy should stay focused on the supply-chain boundary:</p>
<ul>
<li class="">allowed package sources</li>
<li class="">allowed MCP servers</li>
<li class="">allowed transports</li>
<li class="">allowed targets</li>
<li class="">required manifest fields</li>
</ul>
<p>Do not encode every engineering preference in policy. That belongs in reviewed skills, instructions, and architecture guidance.</p>
<p>APM does not replace Azure Policy, Defender for Cloud, Entra ID, workload identity, network controls, human review, or runtime permission models. It is package management and CI governance for agent context.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-practical-caveat">The practical caveat<a href="https://luke.geek.nz/azure/agent-package-manager/#the-practical-caveat" class="hash-link" aria-label="Direct link to The practical caveat" title="Direct link to The practical caveat" translate="no">​</a></h2>
<p>The ecosystem is still young.</p>
<p>In my setup, <code>apm lock --parallel-downloads 0</code> was the reliable path for generating the lockfile. A full install also surfaced useful rough edges: generated hook path drift, target-specific hook naming warnings, and checkout friction in one package on Windows.</p>
<p>I consider that useful signal, not a reason to ignore the model.</p>
<p>The lesson is the same as every other package ecosystem:</p>
<ul>
<li class="">pin what you can</li>
<li class="">audit what you install</li>
<li class="">keep generated outputs boring</li>
<li class="">fix drift before making drift checks mandatory</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-practical-takeaway">The practical takeaway<a href="https://luke.geek.nz/azure/agent-package-manager/#the-practical-takeaway" class="hash-link" aria-label="Direct link to The practical takeaway" title="Direct link to The practical takeaway" translate="no">​</a></h2>
<p>Skills that guide AKS deployment, APIM configuration, Application Insights instrumentation, or Microsoft Foundry work should be versioned and reviewable. MCP servers exposing Azure operations should be declared and explicitly allowed. APM provides the manifest, lockfile, and policy checks for that work.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/agent-package-manager/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://microsoft.github.io/apm/" target="_blank" rel="noopener noreferrer" class="">APM homepage</a></li>
<li class=""><a href="https://microsoft.github.io/apm/reference/targets-matrix/" target="_blank" rel="noopener noreferrer" class="">APM targets matrix</a></li>
<li class=""><a href="https://microsoft.github.io/apm/enterprise/apm-policy/" target="_blank" rel="noopener noreferrer" class="">APM policy overview</a></li>
<li class=""><a href="https://github.com/microsoft/hve-core" target="_blank" rel="noopener noreferrer" class="">Hypervelocity Engineering Core</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Fixing Drasi's 'unusable' Azure AI Search backend: a root cause chase through Kubernetes and Let's Encrypt]]></title>
            <link>https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/</link>
            <guid>https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/</guid>
            <pubDate>Sun, 30 Aug 2026 08:54:59 GMT</pubDate>
            <description><![CDATA[Diagnosing and fixing Drasi's Azure AI Search backend: root-cause of leading-underscore index bug, Kubernetes ingress, cert-manager & Let's Encrypt proxy fix.]]></description>
            <content:encoded><![CDATA[<p>I was wiring up the <a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi</a> SyncVectorStore reaction against Azure AI Search when it crash-looped on startup with what looked like a straightforward Azure AI Search naming restriction. The reaction contains a one-character bug in two places, with no YAML setting, secret reference, or config flag that routes around it. Getting it working meant reading the reaction's source, proving the exact constraint with a live REST test, and building the fix as network infrastructure rather than waiting on an upstream release.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-symptom">The symptom<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#the-symptom" class="hash-link" aria-label="Direct link to The symptom" title="Direct link to The symptom" translate="no">​</a></h2>
<p>Point <code>vectorStoreType</code> at <code>AzureAISearch</code> and the reaction pod crash-loops before it processes a single event:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Vector store connectivity test failed for collection _drasi_test_&lt;hash&gt;</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">... Index name must only contain lowercase letters, digits or dashes</span><br></div></code></pre></div></div>
<p>My first read of that was the obvious one: Azure AI Search must forbid underscores in index names, and the reaction's self-test happens to generate one. Case closed, use a different backend. Except the fix has to survive a search service that's already working correctly for every other collection in the same account, so "underscores are banned" was worth checking rather than assuming.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="reading-the-source-instead-of-the-error-text">Reading the source instead of the error text<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#reading-the-source-instead-of-the-error-text" class="hash-link" aria-label="Direct link to Reading the source instead of the error text" title="Direct link to Reading the source instead of the error text" translate="no">​</a></h2>
<p>The reaction is open source, and the crash trace pointed straight at a specific method. <code>QueryInitializationService.cs</code> runs a connectivity self-test before it touches any of your configuration:</p>
<div class="language-csharp codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-csharp codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">private async Task TestVectorStoreConnectivity(CancellationToken cancellationToken)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    var testCollectionName = $"_drasi_test_{Guid.NewGuid():N}";</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    // ... creates a collection with this exact name, then throws if it fails</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p>That call happens as the literal first line of <code>InitializeQueriesAsync()</code>, before per-query config, before <code>createCollection: false</code> on your own collection gets read, before anything you control has a chance to run. No secret reference, no alternate collection name, no client-side setting avoids it. The self-test builds its own configuration object from scratch every time.</p>
<p>So the question stopped being "how do I configure around this" and became "what is the actual naming rule, and is it really as broad as the error text implies." I tested it directly against the live search service rather than trust the message:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-X</span><span class="token plain"> PUT </span><span class="token string" style="color:rgb(255, 121, 198)">"</span><span class="token string variable" style="color:rgb(189, 147, 249);font-style:italic">$ENDPOINT</span><span class="token string" style="color:rgb(255, 121, 198)">/indexes('drasi_test_abc123')?api-version=2024-07-01"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-H</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"api-key: </span><span class="token string variable" style="color:rgb(189, 147, 249);font-style:italic">$KEY</span><span class="token string" style="color:rgb(255, 121, 198)">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-d</span><span class="token plain"> @schema.json</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># HTTP 201 Created</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token function" style="color:rgb(80, 250, 123)">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-X</span><span class="token plain"> PUT </span><span class="token string" style="color:rgb(255, 121, 198)">"</span><span class="token string variable" style="color:rgb(189, 147, 249);font-style:italic">$ENDPOINT</span><span class="token string" style="color:rgb(255, 121, 198)">/indexes('_drasi_test_abc123')?api-version=2024-07-01"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-H</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"api-key: </span><span class="token string variable" style="color:rgb(189, 147, 249);font-style:italic">$KEY</span><span class="token string" style="color:rgb(255, 121, 198)">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-d</span><span class="token plain"> @schema.json</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># HTTP 400 InvalidName</span><br></div></code></pre></div></div>
<p>Same schema, same account, one character different. Microsoft's own naming-rules documentation confirms the actual constraint: an index name must start with a letter or digit. The error text only calls out the dash rule explicitly, which is why "no underscores" is such an easy wrong conclusion to reach from the message alone. The real rule is narrower: no leading underscore or dash, full stop. Underscores anywhere else in the name are completely fine.</p>
<p>That distinction matters because it changes what the fix has to be. "No underscores" is unfixable without owning the reaction's source. "No leading underscore" is a single character.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bug-shows-up-twice-not-once">The bug shows up twice, not once<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#the-bug-shows-up-twice-not-once" class="hash-link" aria-label="Direct link to The bug shows up twice, not once" title="Direct link to The bug shows up twice, not once" translate="no">​</a></h2>
<p>Once I'd worked around the first occurrence (more on how below), the reaction crash-looped again on a second, more consequential prefix. <code>SyncPointManager.cs</code> hardcodes the name of the permanent collection every reaction instance uses to track what it's already processed:</p>
<div class="language-csharp codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-csharp codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">private const string MetadataCollectionPrefix = "_drasi_metadata_";</span><br></div></code></pre></div></div>
<p>This is the sync-point bookkeeping the reaction depends on for its entire lifetime, created fresh on every bootstrap. An upstream fix limited to the self-test's name would still leave AzureAISearch unusable because this collection hits the same constraint. Both prefixes need the same one-character correction.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-a-proxy-alone-wasnt-enough">Why a proxy alone wasn't enough<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#why-a-proxy-alone-wasnt-enough" class="hash-link" aria-label="Direct link to Why a proxy alone wasn't enough" title="Direct link to Why a proxy alone wasn't enough" translate="no">​</a></h2>
<p>The obvious workaround, once you know the exact string being generated, is a reverse proxy that rewrites the name in flight: catch any request path or body containing <code>_drasi_</code> and strip the leading underscore before it reaches the real service. I built this with <code>mitmproxy</code> in reverse mode and a short addon script, and it worked exactly as expected against the naming check.</p>
<p>It still crashed, on a different exception:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">System.ArgumentException: endpoint only supports https. (Parameter 'endpoint')</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   at Azure.Search.Documents.SearchExtensions.AssertHttpsScheme(Uri endpoint, String paramName)</span><br></div></code></pre></div></div>
<p>The Azure Search SDK refuses a plain HTTP endpoint outright, and there's no configuration surface on the reaction to disable certificate validation either. A self-signed certificate on the proxy wouldn't have helped: the .NET client validates the certificate chain the same way it would for any other HTTPS call, and nothing in the reaction's config lets you skip that. Getting past both problems meant the proxy needed a certificate a real public certificate authority actually issued.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="building-the-rest-of-it">Building the rest of it<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#building-the-rest-of-it" class="hash-link" aria-label="Direct link to Building the rest of it" title="Direct link to Building the rest of it" translate="no">​</a></h2>
<p>Standing up a trusted certificate for a Kubernetes-hosted test service, without owning a domain, took three pieces:</p>
<ul>
<li class=""><code>ingress-nginx</code> and <code>cert-manager</code>, both official Helm charts, installed with no changes beyond the defaults.</li>
<li class="">A <code>ClusterIssuer</code> for Let's Encrypt production, using the HTTP-01 challenge against the ingress.</li>
<li class="">A hostname to issue the certificate for. I looked at <code>dnsbox.io</code> first, which turned out to be self-hosted DNS-authority software you install and run yourself as an internet-facing nameserver, a bigger trust decision than the problem warranted. <code>nip.io</code> is simpler: it's a wildcard DNS resolver that maps an IP address straight into a hostname (<code>20-40-178-12.nip.io</code> resolves to <code>20.40.178.12</code>), no signup, nothing to install, nothing running on your own infrastructure that you'd need to trust.</li>
</ul>
<p>With the ingress's public IP in hand, an <code>Ingress</code> resource pointed at that nip.io hostname, and the ClusterIssuer annotation in place, cert-manager requested a certificate. It failed the first time, and not for a reason that had anything to do with certificates.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-gotcha-that-looked-like-a-firewall-problem">The gotcha that looked like a firewall problem<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#the-gotcha-that-looked-like-a-firewall-problem" class="hash-link" aria-label="Direct link to The gotcha that looked like a firewall problem" title="Direct link to The gotcha that looked like a firewall problem" translate="no">​</a></h2>
<p>External traffic to the ingress timed out completely, both from a plain <code>curl</code> and from Let's Encrypt's own HTTP-01 validator. Every layer I checked looked correct: the pod was <code>Ready</code>, the Kubernetes <code>Service</code> had an external IP, and the network security group had an explicit allow rule for ports 80 and 443 from the internet. None of that mattered.</p>
<p>The actual cause was Azure's Standard Load Balancer, which by default health-checks the same node port your real traffic uses, on path <code>/</code>. <code>ingress-nginx</code>'s default backend returns a <code>404</code> for any request whose <code>Host</code> header doesn't match a configured <code>Ingress</code>, which is exactly what an untargeted Azure health probe sends. Azure marked the backend unhealthy and dropped every packet to it, which looks identical to a firewall block from the outside while every dashboard claims the service is healthy.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl annotate svc ingress-nginx-controller </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-n</span><span class="token plain"> ingress-nginx </span><span class="token punctuation" style="color:rgb(248, 248, 242)">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path</span><span class="token operator">=</span><span class="token plain">/healthz</span><br></div></code></pre></div></div>
<p>That points the probe at ingress-nginx's own always-200 health endpoint instead of the data path. My first attempt at this exact command silently failed for a completely different reason: Git Bash on Windows rewrites a bare leading slash before <code>kubectl</code> ever sees it, so <code>/healthz</code> became <code>C:/Program Files/Git/healthz</code> with no error from anything. <code>MSYS_NO_PATHCONV=1</code> in front of the command, or doubling the leading slash, avoids it. Worth knowing if you're running <code>kubectl</code> or <code>az</code> from Git Bash on Windows and an argument starts with <code>/</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="confirming-the-fix">Confirming the fix<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#confirming-the-fix" class="hash-link" aria-label="Direct link to Confirming the fix" title="Direct link to Confirming the fix" translate="no">​</a></h2>
<p>With the health probe fixed, the certificate issued on the first real attempt. I pointed the reaction's <code>connectionString</code> at the proxy's new HTTPS hostname, triggered a genuine write against the underlying Postgres table, and watched the reaction log a real embedding call and a real upsert. Rather than trust that log line the way I'd learned not to trust the original error text, I checked the actual index directly, with no proxy in the request path this time:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">curl</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"</span><span class="token string variable" style="color:rgb(189, 147, 249);font-style:italic">$ENDPOINT</span><span class="token string" style="color:rgb(255, 121, 198)">/indexes('products-test')/docs('prod-1')?api-version=2024-07-01"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-H</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"api-key: </span><span class="token string variable" style="color:rgb(189, 147, 249);font-style:italic">$KEY</span><span class="token string" style="color:rgb(255, 121, 198)">"</span><br></div></code></pre></div></div>
<p>The response came back with the real product row and its full 3072-dimension embedding vector, stored in the real Azure AI Search service. AzureAISearch isn't a backend to avoid at this platform version. It's a backend that needs one string fixed in two places, and until that ships upstream, a rewrite proxy in front of a properly certificated endpoint gets you the rest of the way there.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="operational-notes">Operational notes<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#operational-notes" class="hash-link" aria-label="Direct link to Operational notes" title="Direct link to Operational notes" translate="no">​</a></h2>
<p>None of this is something to leave running. The proxy holds a real Azure AI Search admin key, and standing up a public ingress means exactly that: public. Within an hour of the certificate issuing, the access log already showed automated scanning traffic hitting the IP on unrelated paths, fake browser user agents probing for open admin panels and API endpoints that don't exist here. Ordinary background noise for anything with a public IP, not an attack on this setup specifically, but a reminder that "it's just a test proxy" doesn't make it invisible.</p>
<p>Nothing here (<code>ingress-nginx</code>, <code>cert-manager</code>, the proxy deployment, the <code>ClusterIssuer</code>) was provisioned through the project's own infrastructure-as-code, so <code>azd down</code> or an equivalent teardown of the managed resources won't remove any of it. Tear it down explicitly, and don't leave a naming-rewrite proxy with a live admin key sitting on the internet longer than the test needs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-is-actually-worth">What this is actually worth<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#what-this-is-actually-worth" class="hash-link" aria-label="Direct link to What this is actually worth" title="Direct link to What this is actually worth" translate="no">​</a></h2>
<p>The specific bug will get fixed or it won't. What's reusable is the checking habit: an error message describes what a service noticed, not necessarily the actual rule. "Index names forbid underscores" was a plausible reading of that error text and it was wrong, in a way that would have sent me down a much longer path (switching vector store backends entirely) if I hadn't tested the boundary case directly. When a config surface won't budge and the fix looks like it needs to live outside the application, ask whether the actual constraint is narrower than the failure message suggests before reaching for infrastructure.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://learn.microsoft.com/rest/api/searchservice/naming-rules?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure AI Search - index naming rules</a></li>
<li class=""><a href="https://github.com/drasi-project/drasi-platform" target="_blank" rel="noopener noreferrer" class="">drasi-project/drasi-platform</a></li>
<li class=""><a href="https://cert-manager.io/docs/" target="_blank" rel="noopener noreferrer" class="">cert-manager documentation</a></li>
<li class=""><a href="https://kubernetes.github.io/ingress-nginx/" target="_blank" rel="noopener noreferrer" class="">ingress-nginx documentation</a></li>
<li class=""><a href="https://nip.io/" target="_blank" rel="noopener noreferrer" class="">nip.io</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Give your Copilot a live data feed with Drasi's MCP Reaction]]></title>
            <link>https://luke.geek.nz/drasi-mcp-live-data-feed/</link>
            <guid>https://luke.geek.nz/drasi-mcp-live-data-feed/</guid>
            <pubDate>Sun, 30 Aug 2026 08:48:38 GMT</pubDate>
            <description><![CDATA[Give Copilot a live data feed with Drasi's MCP Reaction: subscribe to continuous queries for instant push updates—no polling, real-time agent context.]]></description>
            <content:encoded><![CDATA[<p>LLM context is a snapshot. Ask an agent "what's the status of order 47?" and it answers from whatever it retrieved minutes ago, because Model Context Protocol solved tool access but not the other half of the problem, knowing when the underlying data has actually changed. MCP resources are usually read-on-demand: the client asks, the server answers, and nothing happens in between unless the client asks again.</p>
<p><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi</a> has an MCP Reaction that closes that gap. It exposes each continuous query as an MCP resource that a client can subscribe to, and the server pushes a notification the moment the result set changes, no polling, no re-fetching on a timer. I wanted to see whether that actually held up with a real client rather than trusting the resource model on paper, so I connected the official MCP Inspector to a live reaction and watched what happened.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="resources-and-subscriptions-in-ninety-seconds">Resources and subscriptions in ninety seconds<a href="https://luke.geek.nz/drasi-mcp-live-data-feed/#resources-and-subscriptions-in-ninety-seconds" class="hash-link" aria-label="Direct link to Resources and subscriptions in ninety seconds" title="Direct link to Resources and subscriptions in ninety seconds" translate="no">​</a></h2>
<p>Every Drasi continuous query becomes a resource at <code>drasi://query/{id}</code>. A client that subscribes to it starts receiving <code>notifications/resources/updated</code> whenever the result set changes, tagged with the operation type (<code>added</code>, <code>updated</code>, or <code>deleted</code>) and a Handlebars-formatted payload. Your agent doesn't poll, the data arrives when it changes, and until a client subscribes the server does nothing but track state quietly in the background.</p>
<!-- -->
<p>The dotted lines show the model: subscribe once, then the server pushes changes to the client.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-server-yaml-corrected">The server YAML, corrected<a href="https://luke.geek.nz/drasi-mcp-live-data-feed/#the-server-yaml-corrected" class="hash-link" aria-label="Direct link to The server YAML, corrected" title="Direct link to The server YAML, corrected" translate="no">​</a></h2>
<p>The nested YAML shape some inventory examples show does not parse against the pinned platform version. <code>spec.queries.&lt;query-id&gt;</code> has to be a JSON string, the same convention SyncVectorStore uses for its own per-query config, not a nested map with <code>description:</code> and <code>added:</code> as literal YAML keys. Get the shape wrong and it fails fast, before the reaction pod even starts, with <code>400 Bad Request: Json deserialize error: invalid type: map, expected a string</code>.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Reaction</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">apiVersion</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> inventory</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">mcp</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">spec</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> MCP</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">queries</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">low-stock-alerts</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token scalar string" style="color:rgb(255, 121, 198)"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">      {</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "description": "Products below reorder threshold",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "added": {</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">          "template": "{ \"productId\": \"{{after.productId}}\", \"alert\": \"{{after.productName}} is below reorder level\" }"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "updated": {</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">          "template": "{ \"productId\": \"{{after.productId}}\", \"stockChanged\": \"{{before.stock}} -&gt; {{after.stock}}\" }"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "deleted": {</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">          "template": "{ \"productId\": \"{{before.productId}}\", \"alert\": \"back in stock\" }"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        }</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">      }</span><br></div></code></pre></div></div>
<p>The <code>template</code> value is a JSON string nested inside the outer JSON string, so its quotes need escaping. If one level is wrong, the error message does not identify the broken layer. Build the queries one at a time.</p>
<p>The <code>before</code>/<code>after</code> template context here is the same model as EventGrid reaction templates, learn it once and it carries across every Drasi reaction that uses templates.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="tested-with-mcp-inspector">Tested with MCP Inspector<a href="https://luke.geek.nz/drasi-mcp-live-data-feed/#tested-with-mcp-inspector" class="hash-link" aria-label="Direct link to Tested with MCP Inspector" title="Direct link to Tested with MCP Inspector" translate="no">​</a></h2>
<p>I ran the official <a href="https://github.com/modelcontextprotocol/inspector" target="_blank" rel="noopener noreferrer" class="">MCP Inspector</a> against a port-forwarded reaction, connected as <code>streamable-http</code> (the endpoint is the bare root <code>/</code>, not <code>/mcp</code>, worth probing before you assume a path), and subscribed to a query watching a Kubernetes deployment that was sitting under-replicated in the cluster.</p>
<p><img decoding="async" loading="lazy" alt="MCP Inspector subscribed to a Drasi query resource, showing a notifications/resources/updated push arriving from the server the moment a Kubernetes deployment recovers, with the resource content updating from the under-replicated row to an empty result set" src="https://luke.geek.nz/assets/images/mcp-inspector-live-notification-4b87fd4a2726fe9f21a43dddd447eb88.gif" width="1568" height="744" class="img_ev3q"></p>
<p>I then fixed the deployment with a normal <code>kubectl</code> update. A <code>notifications/resources/updated</code> message arrived at the client unprompted, with no preceding request, and re-reading the resource confirmed the payload had gone from the under-replicated row to empty. The live client received the pushed update without polling.</p>
<p>One small thing to check before you build against this yourself: <code>resources/templates/list</code> returns <code>Method not found</code>. That's an unimplemented optional capability, not a bug, the reaction only implements <code>resources/list</code>, <code>resources/read</code>, and <code>resources/subscribe</code>.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>A wired subscription is silent until it isn't</div><div class="admonitionContent_BuS1"><p>A correctly wired subscription is silent until it isn't. After <code>resources/subscribe</code> succeeds, you shouldn't see anything at all from the server, no polling traffic, no periodic re-sends of state that hasn't changed, right up until the underlying result set actually changes. The moment it does, exactly one <code>notifications/resources/updated</code> should arrive, unprompted, and a <code>resources/read</code> straight after should already reflect the new state with no lag. If your client is still issuing <code>resources/read</code> calls on a timer after subscribing, the subscription isn't doing its job and you've quietly rebuilt polling on top of a protocol designed to avoid it. And if you subscribe and hear nothing at all even after a real change, check the reaction's own logs first, the "no subscribers, skipping notification" line I mentioned earlier means the server genuinely doesn't think anyone's listening, which usually means the subscription itself never completed.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pinned-mcp-spec-and-negotiated-client-version">The pinned MCP spec and negotiated client version<a href="https://luke.geek.nz/drasi-mcp-live-data-feed/#the-pinned-mcp-spec-and-negotiated-client-version" class="hash-link" aria-label="Direct link to The pinned MCP spec and negotiated client version" title="Direct link to The pinned MCP spec and negotiated client version" translate="no">​</a></h2>
<p>Drasi's documentation states it pins MCP spec <code>2025-03-26</code>, while upstream MCP has since shipped <code>2025-06-18</code>, <code>2025-11-25</code>, and <code>2026-07-28</code> (the last of which introduced an extensions architecture: Tasks, MCP Apps, Skills over MCP). In practice, put auth enforcement at the gateway, don't expect OIDC discovery metadata, and record the exact spec revision pair in your own ADR before you build against it.</p>
<p>One loose end I haven't resolved: the live Inspector session negotiated protocol version <code>2025-06-18</code> during <code>initialize</code>, not the <code>2025-03-26</code> the documentation states. I don't yet know whether that means the documented pin is stale, or whether the reaction simply accepts whatever version a client requests without enforcing its own. Confirm which before you rely on an exact spec-lag number for your own build, and don't take either side's claim as gospel until you've checked it against your own client.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-client-paths-that-dont-do-this-today">Two client paths that don't do this today<a href="https://luke.geek.nz/drasi-mcp-live-data-feed/#two-client-paths-that-dont-do-this-today" class="hash-link" aria-label="Direct link to Two client paths that don't do this today" title="Direct link to Two client paths that don't do this today" translate="no">​</a></h2>
<p>I checked both the Microsoft Agent Framework SDK and the current Copilot Studio MCP integration. Neither supports resource subscriptions.</p>
<p>Microsoft Agent Framework's built-in MCP support (<code>MCPStreamableHTTPTool</code>, <code>MCPStdioTool</code>) connects to a server and hands the agent its <code>functions</code>. Tools, callable on request. Every code path in the framework that touches an MCP server does the same thing: enumerate the tools, append them to the agent's toolset, call them when the model decides to. There's no <code>resources/subscribe</code> API anywhere in the SDK. To wire up the pattern in this post you'd drop past Agent Framework's MCP wrapper entirely, use a lower-level MCP client library to hold the subscription yourself, and feed whatever arrives into the agent's context on your own terms. That's a real, buildable pattern. I just haven't built and tested it yet, and I'm not going to write it up as five minutes of framework support when it isn't.</p>
<p>Copilot Studio's MCP integration is the same story from a different angle. The current documentation describes exactly one connection shape: add an MCP server as a <strong>tool</strong>, configure a transport and auth, and the agent orchestrator calls it. The sample schema even names the operation <code>InvokeMCP</code>. Nothing in the current docs mentions resource subscriptions or push notifications. If you want your Copilot Studio agent reacting to a Drasi query changing rather than calling it as a tool, today that means bridging it yourself, an HTTP reaction posting to a Power Automate flow or a custom connector, not something Copilot Studio does for you out of the box.</p>
<p>Neither of those is a knock on Drasi. The MCP Reaction does exactly what it claims, subscribe and get pushed updates, and I proved that above with a real client. The lag is on the client ecosystem side, which still treats MCP as tools first, resources later. Plan around that, because both of these frameworks are built on the tools-only assumption today.</p>
<p>Hopefully this gives your Copilot something better to work with than a snapshot from five minutes ago.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/drasi-mcp-live-data-feed/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi documentation</a></li>
<li class=""><a href="https://modelcontextprotocol.io/" target="_blank" rel="noopener noreferrer" class="">Model Context Protocol specification</a></li>
<li class=""><a href="https://github.com/modelcontextprotocol/inspector" target="_blank" rel="noopener noreferrer" class="">MCP Inspector</a></li>
<li class=""><a href="https://github.com/microsoft/agent-framework" target="_blank" rel="noopener noreferrer" class="">Microsoft Agent Framework</a></li>
<li class=""><a href="https://learn.microsoft.com/microsoft-copilot-studio/mcp-add-existing-server-to-agent?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Connect your agent to an existing MCP server - Microsoft Copilot Studio</a></li>
<li class=""><a href="https://github.com/drasi-project/drasi-platform" target="_blank" rel="noopener noreferrer" class="">drasi-project/drasi-platform</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[RAG that never goes stale: continuous vector sync with Drasi]]></title>
            <link>https://luke.geek.nz/drasi-rag-never-goes-stale/</link>
            <guid>https://luke.geek.nz/drasi-rag-never-goes-stale/</guid>
            <pubDate>Sun, 30 Aug 2026 08:48:29 GMT</pubDate>
            <description><![CDATA[Keep RAG embeddings fresh with Drasi's continuous vector sync—per-change embeddings and deletions, lower cost than nightly reindexing.]]></description>
            <content:encoded><![CDATA[<p>Every RAG pipeline I have looked at has the same quiet flaw: embeddings generated at index time go stale when the source system changes, and the standard fix is a nightly reindex job. The job polls and re-embeds the entire corpus every night regardless of how little changed. I tested whether Drasi's SyncVectorStore reaction could invert that with a small products catalogue in Azure Database for PostgreSQL, a continuous query for "in-stock products with a description," and a vector store.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-mental-model-shift">The mental model shift<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#the-mental-model-shift" class="hash-link" aria-label="Direct link to The mental model shift" title="Direct link to The mental model shift" translate="no">​</a></h2>
<p>The continuous query result set is the live corpus. Sync reconciles against that result set, and embeddings happen per change. The embedding bill scales with churn rather than corpus size. A nightly reindex spends N times the corpus in embeddings every day, no matter how little moved.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-working-sample">The working sample<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#the-working-sample" class="hash-link" aria-label="Direct link to The working sample" title="Direct link to The working sample" translate="no">​</a></h2>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">apiVersion</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Source</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> products</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">db</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">spec</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> PostgreSQL</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">properties</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">host</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> &lt;your</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">server</span><span class="token punctuation" style="color:rgb(248, 248, 242)">&gt;</span><span class="token plain">.postgres.database.azure.com</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">port</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token number">5432</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">database</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> products</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">ssl</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean important">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">---</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">apiVersion</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> ContinuousQuery</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> searchable</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">products</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">spec</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">mode</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> query</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">sources</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">subscriptions</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">id</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> products</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">db</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">query</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">&gt;</span><span class="token scalar string" style="color:rgb(255, 121, 198)"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">    MATCH (p:products)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">    WHERE p.instock = true AND p.description IS NOT NULL</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">    RETURN p.id AS product_id, p.name AS name,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">           p.category AS category, p.description AS description</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">---</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">apiVersion</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Reaction</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> product</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">vectors</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">spec</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> SyncVectorStore</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">properties</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">vectorStoreType</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> InMemory </span><span class="token comment" style="color:rgb(98, 114, 164)"># see "upstream bug" below, this is currently the only backend that works end to end</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">embeddingServiceType</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> AzureOpenAI</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">embeddingEndpoint</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">//&lt;your</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">foundry</span><span class="token punctuation" style="color:rgb(248, 248, 242)">&gt;</span><span class="token plain">.openai.azure.com/</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">embeddingModel</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> text</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">embedding</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">3</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">large</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">embeddingApiKey</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">kind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Secret</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> vectorstore</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">keys</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">key</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> openai</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">key</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">embeddingDimensions</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token number">3072</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">distanceFunction</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> CosineSimilarity</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">indexKind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Hnsw</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">queries</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">searchable-products</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token scalar string" style="color:rgb(255, 121, 198)"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">      {</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "collectionName": "products",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "keyField": "product_id",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "documentTemplate": "Product: {{name}}\nCategory: {{category}}\n{{description}}",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "titleTemplate": "{{name}}",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">        "createCollection": true</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token scalar string" style="color:rgb(255, 121, 198)">      }</span><br></div></code></pre></div></div>
<p><code>keyField</code> drives upsert idempotency; duplicates must dedupe on this key or copies accumulate silently. <code>documentTemplate</code> is Handlebars, so shape it for embedding quality as you would any RAG chunk, rather than as a data dump. <code>embeddingServiceType</code> is <code>AzureOpenAI</code> only at the pinned platform version; a guide mentioning "and OpenAI" predates this release.</p>
<p>One config gotcha cost me a round of debugging: <code>Secret</code> references work for <code>embeddingApiKey</code>, but not for <code>connectionString</code>, the object gets passed through verbatim and the connectivity test fails. Apply-time substitution is the workaround for the connection string specifically.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="deletion-semantics-the-headline-result">Deletion semantics, the headline result<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#deletion-semantics-the-headline-result" class="hash-link" aria-label="Direct link to Deletion semantics, the headline result" title="Direct link to Deletion semantics, the headline result" translate="no">​</a></h2>
<p>The central claim of this whole approach is that the continuous query result set <em>is</em> the corpus, continuously reconciled, including the deletion half. I tested this directly rather than trusting the docs to be right about it.</p>
<p>With the pipeline running and one product synced, I ran <code>UPDATE products SET instock = false WHERE id = 'prod-1';</code> directly against Postgres. The product left the continuous query's result set within seconds, and the reaction logs showed the remaining changes.</p>
<p><img decoding="async" loading="lazy" alt="SyncVectorStore reaction logs showing two live change events on the same product: sequence 9 generating a real Azure OpenAI embedding when the row re-enters the result set, and sequence 10 deleting the document from the vector store the moment the row leaves" src="https://luke.geek.nz/assets/images/vectorstore-add-delete-sequence-1241e13277ccd6ed54dd5ee6c0c5f934.png" width="790" height="460" class="img_ev3q"></p>
<p>That's a real Azure OpenAI embedding call and a real vector store deletion, not a description of expected behaviour. When a row leaves the result set the reaction deletes the corresponding document (<code>Deleted: 1</code> in the log), and when the row re-enters, it generates a fresh embedding and adds it back (<code>Added: 1</code>). Deletion included, the claim holds up, which is the bit most of these write-ups skip.</p>
<p>The cost math follows from the same log line. The reaction issues exactly one embedding batch per result-set transition, not per corpus:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Processing change event for query searchable-products with sequence 141.</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Added: 0, Updated: 0, Deleted: 1</span><br></div></code></pre></div></div>
<p>Run the numbers on a 100,000-document catalogue with 1% daily churn and the difference is stark: a nightly reindex costs 100,000 embeddings a day regardless of what changed, Drasi costs roughly 1,000. That's the per-event batch shape scaled up, measured, not projected.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>A healthy pipeline is quiet</div><div class="admonitionContent_BuS1"><p>A healthy pipeline is quiet. Watch the reaction logs and you should see nothing at all for long stretches, then exactly one <code>Processing change event</code> line per write that actually happened, one embedding call, one upsert or delete, done. If you're seeing an embedding batch that scales with your table size rather than with how many rows you actually changed, the reaction isn't reconciling, it's doing something closer to a full rebuild, and that defeats the entire point of using this over a nightly job. The other tell of a working setup: query the vector store directly after a stock flip and the document should already be gone (or already present), not eventually consistent after some polling interval, there shouldn't be a gap between the source changing and the corpus reflecting it.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-post-uses-the-in-memory-backend">Why this post uses the in-memory backend<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#why-this-post-uses-the-in-memory-backend" class="hash-link" aria-label="Direct link to Why this post uses the in-memory backend" title="Direct link to Why this post uses the in-memory backend" translate="no">​</a></h2>
<p>I tested the <code>AzureAISearch</code> backend first, and it is unusable at this platform version. The reaction crash-loops on startup because it hardcodes a leading-underscore index name (<code>_drasi_test_&lt;hash&gt;</code>), which Azure AI Search rejects. It is a one-character bug in the reaction's own code, not a configuration gap on your side.</p>
<p>The <code>InMemory</code> vector store proves the sync, embedding, and reconciliation logic works end to end while that upstream bug gets fixed. The mechanism, including deletion, is backend-agnostic. The separate post below covers the root cause, workaround, and live verification for <code>AzureAISearch</code>:</p>
<ul>
<li class=""><a class="" href="https://luke.geek.nz/drasi-fixing-azure-ai-search-backend/">Fixing Drasi's 'unusable' Azure AI Search backend</a></li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="operational-notes">Operational notes<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#operational-notes" class="hash-link" aria-label="Direct link to Operational notes" title="Direct link to Operational notes" translate="no">​</a></h2>
<ul>
<li class="">Failed embeddings are retried then logged, poison documents are not auto-dropped, alert on the log signature rather than assuming silent success.</li>
<li class="">Sync is asynchronous, vector writes don't block the change pipeline.</li>
<li class=""><code>drasi apply</code> is not an upsert, re-applying an existing resource returns <code>500 Internal Server Error</code> rather than updating it. Delete then apply is the safe pattern for any config change.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="beyond-freshness">Beyond freshness<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#beyond-freshness" class="hash-link" aria-label="Direct link to Beyond freshness" title="Direct link to Beyond freshness" translate="no">​</a></h2>
<p>The same pattern extends past a product catalogue. A continuous query can pre-filter by access control list for permission-aware RAG, so the vector store only ever contains documents a given caller is allowed to see in the first place, rather than filtering after retrieval. A query scoped per tenant gives you a multi-tenant collection the same way, without building that isolation into the reaction itself. Neither of those is something I've tested end to end yet, but the mechanism is identical to what's already proven above, the filter just lives in the <code>WHERE</code> clause instead of the <code>instock</code> flag.</p>
<p>And if you want an agent to see these changes live rather than querying the index at all, Drasi has a different reaction built for exactly that, the MCP Reaction, which exposes a continuous query as a subscribable resource instead of a search index.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/drasi-rag-never-goes-stale/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi documentation</a></li>
<li class=""><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi SyncVectorStore reaction reference</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/search/search-what-is-an-index?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure AI Search - index naming rules</a></li>
<li class=""><a href="https://github.com/drasi-project/drasi-platform" target="_blank" rel="noopener noreferrer" class="">drasi-project/drasi-platform</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Your cluster is a graph: drift detection with Drasi and Cypher]]></title>
            <link>https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/</link>
            <guid>https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/</guid>
            <pubDate>Sun, 30 Aug 2026 08:47:22 GMT</pubDate>
            <description><![CDATA[Detect cross-resource drift in AKS with Drasi and Cypher—standing Cypher queries over your cluster graph for temporal, relational compliance and alerting.]]></description>
            <content:encoded><![CDATA[<p>Every AKS cluster running GitOps already has tools watching for drift. Gatekeeper or Kyverno block bad configuration at admission time. Flux or Argo tell you when the live cluster has drifted from git. Prometheus alerts fire on metrics with a <code>for:</code> duration. None of them are wrong to have, and I am not going to pretend Drasi replaces any of them. What they all share is a boundary: each one is good at the specific thing it watches, and none of them answers a question that cuts across a few resource types at once.</p>
<p>Drasi answers <em>relational</em> questions about live cluster state after admission: is this Deployment still under-replicated after five minutes, and is that Pod running an unapproved image? I tested standing queries over a live AKS resource graph. Only two of the six rules I set out to build survived unchanged; the gap between what looked right on paper and what ran was the useful result.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-each-tool-actually-owns">What each tool actually owns<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#what-each-tool-actually-owns" class="hash-link" aria-label="Direct link to What each tool actually owns" title="Direct link to What each tool actually owns" translate="no">​</a></h2>
<table><thead><tr><th>Tool class</th><th>Owns</th><th>Can't do</th></tr></thead><tbody><tr><td>Gatekeeper / Kyverno</td><td>Admission-time policy (block at create)</td><td>Runtime drift, state that mutates <em>after</em> admission and decays over time</td></tr><tr><td>Flux / Argo</td><td>Git to cluster sync diff</td><td>Arbitrary state questions not tied to git</td></tr><tr><td>Prometheus alerts</td><td>Metrics with a <code>for:</code> duration</td><td>Cross-resource relational joins (Deployment to ReplicaSet to Pod lineage, namespace to NetworkPolicy absence)</td></tr></tbody></table>
<p>Drasi's niche is standing Cypher over the live resource graph, with temporal functions (<code>trueFor</code>, <code>trueLater</code>) and a result set that stays current on its own. Reactions fire on transitions, not on poll ticks.</p>
<!-- -->
<p>Deploy Drasi into the same cluster, or point an external instance at it via a kubeconfig Secret. RBAC matters here: the source credential only needs <code>list</code>/<code>watch</code> on the resource types your rules actually read, scope it the way you'd scope a read-only dashboard service account.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-rulebook-corrected">The rulebook, corrected<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#the-rulebook-corrected" class="hash-link" aria-label="Direct link to The rulebook, corrected" title="Direct link to The rulebook, corrected" translate="no">​</a></h2>
<p>I wrote six rules against the documentation first, then ran every one of them against a live cluster with deliberately induced violations. Only two survived unchanged. Two needed rewriting, and two don't work at all, and that last pair isn't because of a query bug.</p>
<table><thead><tr><th>Rule</th><th>Verdict</th><th>Why</th></tr></thead><tbody><tr><td>Under-replicated sustained</td><td>Works, rewritten</td><td>Needed <code>coalesce(status.readyReplicas, 0)</code>, the field is null during rollout</td></tr><tr><td>Unapproved registry images</td><td>Works, rewritten</td><td><code>STARTS WITH</code> doesn't parse in the pinned Cypher subset</td></tr><tr><td>Namespaces without NetworkPolicy</td><td>Cut</td><td>NetworkPolicy and Namespace aren't in the Kubernetes Source's fixed watch list</td></tr><tr><td>Certificate expiry (TLS Secrets)</td><td>Cut</td><td>Secret isn't in the watch list either</td></tr><tr><td>PodDisruptionBudget exhaustion</td><td>Cut</td><td>PodDisruptionBudget isn't watched</td></tr><tr><td>Sustained node pressure</td><td>Works, rewritten, syntax proven</td><td>Node is watched, needs the same <code>unwind</code> as containers, live violation deliberately not induced (shared control-plane node)</td></tr></tbody></table>
<p>The Kubernetes source watches a fixed list of twelve resource types, no matter what your queries reference: Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Job, Service, ServiceAccount, Node, Ingress, PersistentVolume, and PersistentVolumeClaim. Namespace, NetworkPolicy, PodDisruptionBudget, and Secret aren't on that list, and no RBAC grant changes it. So those three cut rules are a platform limitation, not a query bug. You work around it by picking a different resource to watch, not by Cypher-ing your way out of it.</p>
<p>The cut rules all trace back to the watch list, not bad query logic. Three replacement rules earned a spot. Two are proven against real violations; I only proved the third on syntax.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="under-replicated-deployments-sustained">Under-replicated deployments, sustained<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#under-replicated-deployments-sustained" class="hash-link" aria-label="Direct link to Under-replicated deployments, sustained" title="Direct link to Under-replicated deployments, sustained" translate="no">​</a></h3>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (d:Deployment)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE d.spec.replicas &gt; coalesce(d.status.readyReplicas, 0)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  AND drasi.trueFor(d.spec.replicas &gt; coalesce(d.status.readyReplicas, 0),</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        duration({ minutes: 5 }))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN d.metadata.name AS name, d.metadata.namespace AS namespace,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">       d.spec.replicas AS desired, coalesce(d.status.readyReplicas, 0) AS ready</span><br></div></code></pre></div></div>
<p>The <code>trueFor</code> debounce is the whole point here, flapping during a normal rollout doesn't fire, genuine degradation does. <code>readyReplicas</code> is null during rollout, so <code>coalesce</code> isn't optional, the original query without it errors on any deployment mid-rollout.</p>
<p>I induced a real violation (an impossible <code>nodeSelector</code> that could never schedule) and tested with a shortened five-minute window rather than production timing, purely to see the transition without a long wait. It fired as expected once the debounce elapsed:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"desired"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token number">2</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"name"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"broken-app"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"namespace"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"drift-test"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"ready"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token number">0</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="images-outside-the-approved-registry">Images outside the approved registry<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#images-outside-the-approved-registry" class="hash-link" aria-label="Direct link to Images outside the approved registry" title="Direct link to Images outside the approved registry" translate="no">​</a></h3>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (p:Pod)-[:HAS]-&gt;(c:Container)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE left(c.image, 22) &lt;&gt; 'ghcr.io/drasi-project/'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  AND left(c.image, 18) &lt;&gt; 'mcr.microsoft.com/'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN p.metadata.namespace AS namespace, p.metadata.name AS pod, c.image AS image</span><br></div></code></pre></div></div>
<p><code>STARTS WITH</code>, and <code>CONTAINS</code> and <code>ENDS WITH</code> alongside it, don't exist in the pinned Cypher parser. <code>left(image, N) &lt;&gt; 'prefix'</code> is the working shape. Admission policy already blocks these at create, this rule catches anything that mutates state afterward, a debug override, a manual <code>kubectl</code>, or compromised tooling.</p>
<p><img decoding="async" loading="lazy" alt="Debug Reaction gateway JSON showing the live unapproved-images and under-replicated-deployments results: broken-app under-replicated, the intended registry-offender catch, and the two real allowlist gotchas, a Drasi pod&amp;#39;s own dapr sidecar and a digest-pinned calico image with no registry prefix" src="https://luke.geek.nz/assets/images/rulebook-live-results-5fe3eaf1ed18a6e06c2e3bea9fe28906.png" width="790" height="350" class="img_ev3q"></p>
<p>The live result set correctly flagged a <code>docker.io/library/nginx</code> deployment I'd deliberately mislabelled. It also surfaced two allowlist cases that a toy cluster would miss:</p>
<ul>
<li class="">Dapr sidecars report as <code>docker.io/daprio/daprd:1.14.5</code>. Allowlist <code>docker.io/daprio/</code> explicitly, or every Drasi pod with a sidecar flags.</li>
<li class="">Calico components report a bare <code>sha256:&lt;digest&gt;</code>, with no registry prefix at all. A prefix-based rule is blind to digest-pinned images, and that's a genuine blind spot worth calling out rather than a footnote.</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="cut-namespaces-without-a-networkpolicy">Cut: namespaces without a NetworkPolicy<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#cut-namespaces-without-a-networkpolicy" class="hash-link" aria-label="Direct link to Cut: namespaces without a NetworkPolicy" title="Direct link to Cut: namespaces without a NetworkPolicy" translate="no">​</a></h3>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">-- doesn't run: NetworkPolicy and Namespace aren't in the Kubernetes</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">-- Source's fixed watch list</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (n:Namespace)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE NOT EXISTS { MATCH (n)-[:HAS]-&gt;(:NetworkPolicy) }</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN n.metadata.name</span><br></div></code></pre></div></div>
<p>Two independent problems stack up here. <code>EXISTS { MATCH ... }</code> existential subqueries don't parse in this Cypher subset regardless, and even a rewritten version has nothing to match against, because Namespace and NetworkPolicy nodes never materialise in the graph. The graph shape genuinely does make absence first-class, a standing query can express "this relationship never showed up" in a way a metrics system struggles with, just not for resource types the source doesn't watch yet.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="cut-certificate-expiry-and-poddisruptionbudget-exhaustion">Cut: certificate expiry and PodDisruptionBudget exhaustion<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#cut-certificate-expiry-and-poddisruptionbudget-exhaustion" class="hash-link" aria-label="Direct link to Cut: certificate expiry and PodDisruptionBudget exhaustion" title="Direct link to Cut: certificate expiry and PodDisruptionBudget exhaustion" translate="no">​</a></h3>
<p>Both hit the same wall. Certificate expiry needs to watch Secrets, PDB exhaustion needs to watch PodDisruptionBudgets, and neither resource type is on the fixed watch list. The <code>trueLater</code> self-scheduling idea behind the certificate rule is still the right teaching point, no cron job scanning secrets on a timer, it just isn't provable against Kubernetes Secrets today. If cert-manager's <code>Certificate</code> CRD is on your cluster, that's the more promising angle, though I haven't tested it.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="new-sustained-node-pressure-syntax-proven-not-yet-fired-on-a-real-violation">New: sustained node pressure, syntax proven, not yet fired on a real violation<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#new-sustained-node-pressure-syntax-proven-not-yet-fired-on-a-real-violation" class="hash-link" aria-label="Direct link to New: sustained node pressure, syntax proven, not yet fired on a real violation" title="Direct link to New: sustained node pressure, syntax proven, not yet fired on a real violation" translate="no">​</a></h3>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (n:Node)-[:HAS]-&gt;(c:NodeCondition)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE c.type IN ['MemoryPressure', 'DiskPressure']</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  AND c.status = 'True'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  AND drasi.trueFor(c.type IN ['MemoryPressure', 'DiskPressure'] AND c.status = 'True',</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        duration({ seconds: 60 }))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN n.metadata.name AS node, c.type AS condition, c.status AS status</span><br></div></code></pre></div></div>
<p>Node is on the watch list, but its conditions live under <code>status.conditions</code>, the same array shape as a container's status, so it needs the same <code>unwind</code> treatment as the crashlooping rule below. It applies cleanly and reaches <code>Running</code>, correctly empty against a healthy cluster, both nodes reporting <code>MemoryPressure: false</code> and <code>DiskPressure: false</code> throughout. What I didn't do is force an actual violation to prove the <code>trueFor</code> transition, this node is shared with Drasi's own control plane, and deliberately exhausting its memory to trigger a real pressure condition risks taking down the whole environment for the sake of one test. The syntax and the unwind mechanics are proven, the live transition isn't, and I'd rather say that plainly than claim a clean fire I didn't actually see.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="new-crashlooping-containers">New: crashlooping containers<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#new-crashlooping-containers" class="hash-link" aria-label="Direct link to New: crashlooping containers" title="Direct link to New: crashlooping containers" translate="no">​</a></h3>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (c:Container)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE c.state.waiting.reason = 'CrashLoopBackOff'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN c.metadata.namespace, c.metadata.name</span><br></div></code></pre></div></div>
<p>This needs an <code>unwind</code> middleware to extract <code>containerStatuses</code> off each Pod into its own <code>Container</code> nodes first. The middleware block nests under <code>sources:</code> in the manifest, not <code>spec:</code>, get that wrong and the apply silently fails with no obviously helpful error. Correctly empty against a healthy cluster, then fired the moment I pushed a container into <code>CrashLoopBackOff</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="new-replicaless-deployments">New: replicaless deployments<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#new-replicaless-deployments" class="hash-link" aria-label="Direct link to New: replicaless deployments" title="Direct link to New: replicaless deployments" translate="no">​</a></h3>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (d:Deployment)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">OPTIONAL MATCH (d)-[:owns]-&gt;(rs:ReplicaSet)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WITH d, count(rs) AS replicaSetCount</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE replicaSetCount = 0</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN d.metadata.namespace, d.metadata.name</span><br></div></code></pre></div></div>
<p>My first instinct was an <code>EXISTS {}</code> subquery again, same parser problem. <code>OPTIONAL MATCH</code> with <code>count()</code> is the working equivalent, and it correctly stayed empty, every deployment on the test cluster had a ReplicaSet, as expected.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>A healthy rulebook is boring</div><div class="admonitionContent_BuS1"><p>A healthy rulebook is boring almost all the time. On a compliant cluster, all five working rules above should sit empty, no rows, no noise, and stay that way. The only time you should see a row appear is the instant a real violation crosses its threshold, <code>under-replicated-deployments</code> after five full minutes of a genuine shortfall, <code>unapproved-images</code> the moment a disallowed image actually runs, and the row should disappear again the instant the underlying problem is fixed, not on some polling delay. If a rule is firing constantly against a healthy cluster, that's not the cluster's fault, go back and check the query first, <code>coalesce</code> missing somewhere, or a debounce window too short for normal rollout behaviour are the usual causes. And if a rule silently returns nothing no matter what you break, don't assume the cluster's fine, check whether the resource type is even on the source's watch list before you trust an empty result set.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="parser-surface-learned-the-hard-way">Parser surface, learned the hard way<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#parser-surface-learned-the-hard-way" class="hash-link" aria-label="Direct link to Parser surface, learned the hard way" title="Direct link to Parser surface, learned the hard way" translate="no">​</a></h2>
<p>None of the following came from documentation, I read it off the query-host's own error diagnostics:</p>
<ul>
<li class="">Supported comparisons: <code>=</code>, <code>&lt;&gt;</code>, <code>!=</code>, <code>&lt;</code>, <code>&lt;=</code>, <code>&gt;</code>, <code>&gt;=</code>, <code>IN</code>, <code>IS</code>, plus arithmetic</li>
<li class="">Not supported: <code>STARTS WITH</code>, <code>CONTAINS</code>, <code>ENDS WITH</code>, <code>=~</code> regex, and <code>EXISTS { MATCH }</code> existential subqueries</li>
<li class="">Supported: <code>left()</code>, <code>coalesce()</code>, <code>OPTIONAL MATCH</code> with aggregation, and the <code>unwind</code> middleware</li>
<li class="">Middleware nests under <code>sources:</code> in the manifest, not <code>spec:</code></li>
</ul>
<p>One small bonus for anyone verifying this themselves: listing the actual pod images on the test cluster exposed the platform's real component names (<code>query-container-query-host</code>, <code>query-container-view-svc</code>, <code>query-container-publish-api</code>, <code>source-query-api</code>, <code>source-change-dispatcher</code>, <code>source-change-router</code>), which explains why bare <code>query-host</code> and <code>view-svc</code> registry probes always come back 404.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="alert-philosophy-mapping">Alert philosophy mapping<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#alert-philosophy-mapping" class="hash-link" aria-label="Direct link to Alert philosophy mapping" title="Direct link to Alert philosophy mapping" translate="no">​</a></h2>
<table><thead><tr><th>Rule</th><th>Pillar</th><th>Route</th><th>Status</th></tr></thead><tbody><tr><td>Under-replicated sustained</td><td>Reliability</td><td>Page</td><td>Proven live</td></tr><tr><td>Unapproved registry</td><td>Security</td><td>Ticket + Teams</td><td>Proven live</td></tr><tr><td>Crashlooping containers</td><td>Reliability</td><td>Page</td><td>Proven live</td></tr><tr><td>Replicaless deployments</td><td>Reliability</td><td>Ticket</td><td>Proven live</td></tr><tr><td>Missing NetworkPolicy</td><td>Security</td><td>Ticket</td><td>Not queryable today</td></tr><tr><td>Cert expiry</td><td>Reliability</td><td>Ticket, 30-day runway</td><td>Not queryable today</td></tr><tr><td>PDB exhausted</td><td>Reliability</td><td>Page during change freezes</td><td>Not queryable today</td></tr><tr><td>Node pressure sustained</td><td>Cost/OpEx</td><td>Ticket</td><td>Syntax proven, live fire not induced</td></tr></tbody></table>
<p>The rulebook is version-controlled YAML, reviewed like code, for the resources the source can actually see today.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="operational-notes-from-a-live-build">Operational notes from a live build<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#operational-notes-from-a-live-build" class="hash-link" aria-label="Direct link to Operational notes from a live build" title="Direct link to Operational notes from a live build" translate="no">​</a></h2>
<ul>
<li class="">Kubeconfig is stored as a Kubernetes Secret. Avoid exec-based kubeconfigs in runtime containers.</li>
<li class="">Watch for <code>ResourceVersionTooOld</code> events on the source namespace, a desynced watch cache silently misses changes without raising anything obvious.</li>
<li class="">Modifying the Kubernetes Source means delete and recreate, plus recreating any dependent queries, unlike SQL Server sources which re-apply cleanly by name.</li>
<li class="">Default provider registration is automatic after <code>drasi init</code>. Older guidance saying you need to apply the default provider manifests yourself is stale for the current CLI generation.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="when-not-to-do-this">When not to do this<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#when-not-to-do-this" class="hash-link" aria-label="Direct link to When not to do this" title="Direct link to When not to do this" translate="no">​</a></h2>
<p>Admission enforcement still belongs to Gatekeeper or Kyverno, Drasi observes, it doesn't block. Git-sync drift still belongs to Flux or Argo. And keep one Drasi instance per cluster, don't point a single instance across clusters, that's a lesson I only half learned by reading about it.</p>
<p>Hopefully this saves you from rediscovering the watch-list gap the hard way. Five working rules against a real cluster, with the exact syntax that actually parses, is a better starting rulebook than six that look right on paper.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/drasi-kubernetes-drift-detection-cypher/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi documentation</a></li>
<li class=""><a href="https://drasi.io/drasi-kubernetes/how-to-guides/configure-sources/" target="_blank" rel="noopener noreferrer" class="">Drasi Kubernetes Source configuration</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/aks/manage-azure-rbac?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Kubernetes Service RBAC</a></li>
<li class=""><a href="https://github.com/drasi-project/drasi-platform" target="_blank" rel="noopener noreferrer" class="">drasi-project/drasi-platform</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Detecting absence: querying for the events that never happened]]></title>
            <link>https://luke.geek.nz/drasi-detecting-absence/</link>
            <guid>https://luke.geek.nz/drasi-detecting-absence/</guid>
            <pubDate>Sun, 30 Aug 2026 08:47:11 GMT</pubDate>
            <description><![CDATA[Detecting absence with Drasi: continuous PostgreSQL queries (trueLater/trueFor) to alert when events stop—replace pollers with real-time, reliable alerts.]]></description>
            <content:encoded><![CDATA[<p>I have written more "check if X hasn't happened in N minutes" pollers than I care to admit. A timer-triggered Function, a state table to remember the last time you checked, some dedup logic so you don't page the same person twice, and a nagging feeling that the poll interval itself is a bug waiting to happen (miss the window and the alert arrives late, or not at all). It works, but it is infrastructure you built to answer a question your database should just be able to answer on its own.</p>
<p><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi</a>, a CNCF Sandbox project for continuous queries over changing data, handles a question SQL cannot express well: "alert me when something <em>stops</em> happening." I have been testing it against a real AKS cluster and Azure Database for PostgreSQL.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-polling-tax">The polling tax<a href="https://luke.geek.nz/drasi-detecting-absence/#the-polling-tax" class="hash-link" aria-label="Direct link to The polling tax" title="Direct link to The polling tax" translate="no">​</a></h2>
<p>"Alert me if a device hasn't checked in for 20 minutes" sounds like a simple requirement until you have to build it. The honest version needs a poller running on a schedule, a query for <code>max(last_seen)</code> per device, a state table so you don't re-fire the same alert every poll cycle, and careful handling of the boundary between polls (a device that goes quiet at minute 19 of a 20-minute check shouldn't wait an extra cycle to be noticed). None of that logic is about the business problem. It compensates for a normal query's inability to identify an event that failed to occur during a time window.</p>
<p>Drasi's standing queries flip this. You write the query once, and the result set is always current, maintained continuously as the underlying data changes, rather than recomputed on a schedule.</p>
<!-- -->
<p>The left side is every poller I have ever written, and that's the set of problems that sold me on this. The right side is the same requirement with the scaffolding gone, no timer, no state table, no dedup logic. The result set is the state now, it maintains itself, and for absence specifically a pair of temporal functions carry the whole thing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-function-toolbox">The function toolbox<a href="https://luke.geek.nz/drasi-detecting-absence/#the-function-toolbox" class="hash-link" aria-label="Direct link to The function toolbox" title="Direct link to The function toolbox" translate="no">​</a></h2>
<table><thead><tr><th>Function</th><th>Signature</th><th>Semantics</th></tr></thead><tbody><tr><td><code>drasi.trueFor</code></td><td><code>trueFor(expr, duration)</code></td><td><code>expr</code> must stay true continuously for <code>duration</code> from the change time before an <code>added</code> result fires</td></tr><tr><td><code>drasi.trueLater</code></td><td><code>trueLater(expr, timestamp)</code></td><td>evaluates <code>expr</code> at a future <code>timestamp</code>, returns <code>drasi.awaiting</code> meanwhile and re-schedules</td></tr><tr><td><code>drasi.trueUntil</code></td><td><code>trueUntil(expr, timestamp)</code></td><td><code>expr</code> must remain true until <code>timestamp</code></td></tr></tbody></table>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>danger</div><div class="admonitionContent_BuS1"><p><code>drasi.slidingWindow</code> is documented (it's in the official Drasi custom functions reference), but it is not registered at the pinned 0.10.0 platform release. I wrote a query against it expecting an aggregation over a trailing window, and got <code>UnknownFunction("drasi.slidingWindow")</code> back instead, a hard failure, not a silent no-op. If you want a trailing-window aggregation today, you'll need to check the function actually exists on your pinned release before you build anything on top of it, the documentation is ahead of what 0.10.0 actually ships.</p></div></div>
<p>While <code>trueLater</code> waits for its scheduled timestamp, it returns the special value <code>drasi.awaiting</code>, so the result set holds rows that have not resolved yet. I tested this against a live cluster, and the behaviour is specific to <code>trueLater</code>: a <code>trueFor</code> row stays hidden until the debounce window elapses and never surfaces in an awaiting state. Conflating the two makes reaction logic treat a normal pending row as a bug.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="worked-example-the-silent-sensor">Worked example: the silent sensor<a href="https://luke.geek.nz/drasi-detecting-absence/#worked-example-the-silent-sensor" class="hash-link" aria-label="Direct link to Worked example: the silent sensor" title="Direct link to Worked example: the silent sensor" translate="no">​</a></h2>
<p>The pattern that sold me on the whole model is the "device gone quiet" query, because it has to do two things at once: catch devices that are <em>already</em> overdue when the query is created, and schedule a future check for devices that will go quiet later.</p>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (d:devices)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE drasi.trueLater(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      datetime() &gt;= datetime(d.lastseen + 'Z') + duration({ minutes: 20 }),</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      datetime(d.lastseen + 'Z') + duration({ minutes: 20 }))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN d.id AS id, d.lastseen AS lastseen</span><br></div></code></pre></div></div>
<p>The <code>+ 'Z'</code> appends the timezone marker. PostgreSQL's <code>timestamptz</code> columns arrive over the wire as offset-less ISO strings (<code>2026-08-26T05:35:41.699091</code>, no timezone marker), and feeding one directly into <code>datetime()</code> throws <code>FunctionError: InvalidFormat</code> because it requires an RFC 3339 string. The first bad row terminal-errors the whole query. Appending the offset before parsing fixes it.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>warning</div><div class="admonitionContent_BuS1"><p>Still chasing this one, so treat it as an open question rather than a fix: after this query had been running cleanly for a while, I inserted and then deleted a device row on the same table, and recreating the query afterwards hit the identical <code>datetime</code> error against rows that had parsed fine for hours. My working theory is that once a row in a CDC-tracked table goes through a live streaming-replication event, the source's cached representation of its <code>timestamptz</code> values can flip from offset-less to offset-included, which would silently break the <code>+ 'Z'</code> fix. I haven't nailed the root cause yet. If you hit this, check whether the affected table has had any live inserts or deletes since the query last bootstrapped cleanly.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="worked-example-unacknowledged-alert-escalation">Worked example: unacknowledged alert escalation<a href="https://luke.geek.nz/drasi-detecting-absence/#worked-example-unacknowledged-alert-escalation" class="hash-link" aria-label="Direct link to Worked example: unacknowledged alert escalation" title="Direct link to Worked example: unacknowledged alert escalation" translate="no">​</a></h2>
<p>The silent-sensor pattern schedules one future check per device. The second half of absence detection is the sustained condition: not "has it gone quiet," but "has this been true for too long." That is a different function, <code>trueFor</code>, and it is the difference between escalation-as-a-query and escalation-as-a-workflow-engine:</p>
<div class="language-cypher codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cypher codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">MATCH (a:alerts)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">WHERE a.severity = 'P1'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  AND a.acknowledgedby IS NULL</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  AND drasi.trueFor(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        a.severity = 'P1' AND a.acknowledgedby IS NULL,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        duration({ seconds: 60 }))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">RETURN a.id AS id, a.severity AS severity, a.raisedat AS raisedat</span><br></div></code></pre></div></div>
<p><code>trueLater</code> puts a row in the result set at a future moment; <code>trueFor</code> keeps a row out until a condition has held continuously. One schedules and the other debounces. Together they cover absence detection.</p>
<p>I tested this with a shortened 60-second window rather than a real SLA, purely to see the transition without waiting around. <code>ALR-100</code>, a P1 alert with nobody assigned, entered the result set only once the full 60 seconds of continuous truth had elapsed, never before. Use whatever duration your actual SLA needs, the mechanism doesn't care.</p>
<p><img decoding="async" loading="lazy" alt="Debug Reaction gateway JSON showing ALR-100 in the unacked-p1-alerts result set after the trueFor debounce, and three devices sitting in the silent-devices result set in the trueLater awaiting state with their lastseen timestamps" src="https://luke.geek.nz/assets/images/truefor-truelater-debug-reaction-693229205bd0467e3d492d5a4f9c558e.png" width="790" height="460" class="img_ev3q"></p>
<p>Both results above came straight off the Debug Reaction gateway (<code>GET /api/queries/{id}</code>) against a live cluster, not a docs example. The three devices sitting in the <code>silent-devices</code> result set are the <code>trueLater</code> awaiting state in practice, present in the result well before their 20-minute deadlines, each carrying its <code>lastseen</code> value so a reaction can show useful context rather than a bare row.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Reading the two result sets</div><div class="admonitionContent_BuS1"><p>Once this is wired up correctly, here's the behaviour you should actually see, and what tells you something's wrong if you don't. <code>unacked-p1-alerts</code> stays empty the entire time an alert is unacknowledged but young, gains a row the instant the debounce window elapses, and never gains a row at all if someone acknowledges it first. If a row appears immediately on creation, your <code>trueFor</code> isn't debouncing, check the duration argument. <code>silent-devices</code> should show every device currently being tracked, each one sitting quietly with its real <code>lastseen</code> timestamp, <code>awaiting</code> rather than absent, right up until its 20-minute deadline actually passes. If a device is missing from the result set entirely rather than present-and-awaiting, that's the sign something upstream of the query, not the query itself, has gone wrong.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotchas-from-a-live-build">Gotchas from a live build<a href="https://luke.geek.nz/drasi-detecting-absence/#gotchas-from-a-live-build" class="hash-link" aria-label="Direct link to Gotchas from a live build" title="Direct link to Gotchas from a live build" translate="no">​</a></h2>
<p>Each of these cost me a debugging round against a real Azure PostgreSQL Flexible Server and AKS, so here they are up front rather than buried in a support thread somewhere:</p>
<ol>
<li class=""><strong>Grant ordering.</strong> Running <code>GRANT SELECT ... ON ALL TABLES</code> before the tables exist grants nothing to anything created afterwards. Bootstrap then fails with <code>permission denied for table</code>.</li>
<li class=""><strong><code>timestamptz</code> arrives as an offset-less ISO string.</strong> Covered above, <code>datetime(col)</code> alone throws and terminal-errors the whole query.</li>
<li class=""><strong>The 0.10.x PostgreSQL source manages its own filtered publication</strong> (<code>rg_&lt;source-name&gt;</code>), in addition to any you create yourself. It needs, in order, <code>CREATE ON DATABASE</code>, table ownership (for publication membership, which itself needs <code>GRANT drasi_user TO admin</code>), and <code>CREATE ON SCHEMA public</code> for the new owner. Bootstrap works fine without any of this, streaming just dies silently, which is the trap.</li>
<li class=""><strong><code>wal_level=logical</code> is a static Postgres parameter.</strong> ARM reports <code>value=logical</code> the instant you set it, but the running server stays on <code>replica</code> until a full restart. Symptom: bootstrap succeeds, zero changes ever stream, and slot creation fails with "logical decoding requires wal_level &gt;= logical."</li>
<li class=""><strong>The server admin login doesn't have the <code>REPLICATION</code> attribute by default</strong>, even as a member of <code>azure_pg_admin</code>. Point a source straight at the admin login (rather than a dedicated replication role) and it fails with a different error again, <code>FATAL: permission denied to start WAL sender</code>. Fix: <code>ALTER ROLE &lt;login&gt; WITH REPLICATION;</code>.</li>
<li class=""><strong>Deleting a row can crash the entire reactivator, not just the affected query.</strong> Postgres's default <code>REPLICA IDENTITY</code> only sends primary-key columns in a DELETE's old-row image, everything else arrives as <code>null</code>. Drasi generates a message schema mirroring your <code>NOT NULL</code> constraints, so a legitimately-null column in that DELETE event fails the generated schema's own validation and takes the whole connector down (<code>Invalid value: null used for required field</code>, then <code>CrashLoopBackOff</code>). Run <code>ALTER TABLE &lt;table&gt; REPLICA IDENTITY FULL;</code> on any watched table with <code>NOT NULL</code> columns outside the primary key, before you ever delete a row from it.</li>
<li class=""><strong><code>drasi apply</code> is not an upsert.</strong> Re-applying an existing <code>ContinuousQuery</code> returns <code>500 Internal Server Error</code>, and it blocks every other document in the same multi-doc file from applying. Delete, then apply.</li>
<li class=""><strong>Default providers are auto-registered.</strong> Older guidance saying source and reaction providers need a separate apply step after <code>drasi init</code> is stale for the current CLI generation, I watched them register automatically during init.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="when-not-to-do-this">When not to do this<a href="https://luke.geek.nz/drasi-detecting-absence/#when-not-to-do-this" class="hash-link" aria-label="Direct link to When not to do this" title="Direct link to When not to do this" translate="no">​</a></h2>
<p>Standing queries are the wrong tool for high-frequency flapping conditions on their own, a raw absence check on something that oscillates rapidly will fire constantly. Combine it with <code>trueFor</code> to debounce rather than alerting on every raw transition, the escalation example above is really that same pattern applied to alert acknowledgement.</p>
<p>That's enough to go and swap a poller for a standing query. The gotchas above are the ones that cost me actual debugging rounds, so hopefully they read as a head start rather than a repeat of the same wall.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/drasi-detecting-absence/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi documentation</a></li>
<li class=""><a href="https://drasi.io/reference/query-language/drasi-custom-functions/" target="_blank" rel="noopener noreferrer" class="">Drasi custom functions reference</a></li>
<li class=""><a href="https://drasi.io/drasi-kubernetes/how-to-guides/installation/" target="_blank" rel="noopener noreferrer" class="">Drasi for Kubernetes installation guides</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/postgresql/configure-maintain/concepts-logical?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Database for PostgreSQL Flexible Server - logical replication</a></li>
<li class=""><a href="https://github.com/drasi-project/drasi-platform" target="_blank" rel="noopener noreferrer" class="">drasi-project/drasi-platform</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Evidence to Decision: building an AI-envisioning workshop tool on Azure]]></title>
            <link>https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/</link>
            <guid>https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/</guid>
            <pubDate>Thu, 20 Aug 2026 01:45:42 GMT</pubDate>
            <description><![CDATA[How I built a governed, evidence-first AI workshop pipeline on Azure, from sticky notes to approved delivery documents, with every step auditable.]]></description>
            <content:encoded><![CDATA[<p>Most "AI workshop" tooling is either a whiteboard with sticky notes or a slide deck. I built something different. <a href="https://github.com/lukemurraynz/evidence-to-decision" target="_blank" rel="noopener noreferrer" class="">Evidence to Decision</a> is a governed, evidence-first pipeline. A facilitator captures what people actually said, the room votes and clusters live on a shared mural, an Azure AI agent drafts a starting problem framing from that evidence alone, a reviewer approves or blocks it, and only then does it become a delivery document. Every step keeps the paper trail. This is why I shaped it that way, and what it took to build on Azure.</p>
<blockquote>
<p>You've been in the room. The energy is good, the sticky notes pile up faster than you can read them, and half the team thinks the key insight was something someone mentioned in passing at 2:15 PM. When you're trying to hand off to delivery two weeks later, the connection between "what a customer said" and "what the team decided to build" usually lives in someone's memory, not a record.</p>
</blockquote>
<p>That gap is the point of Evidence to Decision. I set one constraint and let it drive everything else: evidence stays attributable, and decisions stay accountable. An AI agent can draft and recommend, but it can never approve or mutate canonical state. That rule shapes the domain model, the agent prompts, and the UI.</p>
<p><img decoding="async" loading="lazy" alt="Evidence to Decision: full workshop journey" src="https://luke.geek.nz/assets/images/evidence-to-decision-journey-c9696918a899397d036f874fd7966768.gif" width="1317" height="896" class="img_ev3q"></p>
<p>The pipeline works like this: facilitators capture evidence (what was said, by whom, about what), the room runs through ideation rounds with live voting and clustering on a shared mural, an AI agent drafts a candidate problem frame citing the evidence it sees, a reviewer approves or blocks the draft, and only then does the frame become canonical. Every step is a separate surface, every transition requires human judgment, and the AI is advisory-only.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="architecture">Architecture<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#architecture" class="hash-link" aria-label="Direct link to Architecture" title="Direct link to Architecture" translate="no">​</a></h2>
<p>I went for clean architecture: Domain / Application / Infrastructure / API layers, with a single canonical <code>OpportunityGraph</code> per engagement as the only authoritative state. Everything else (recommendations, cards, review views, handoff artefacts) is derived and cannot approve or mutate it.</p>
<p><img decoding="async" loading="lazy" alt="Evidence to Decision architecture" src="https://luke.geek.nz/assets/images/architecture-overview.drawio-6459288f074c8233c2bf2386483409b6.png" width="5379" height="3618" class="img_ev3q"></p>
<p>Cosmos DB holds an event-style record of the graph, with optimistic concurrency via ETags on every mutation. It is the only system of record.</p>
<p>Azure SignalR Service backs the live surfaces. Vote tallies update as people vote, live cursors show where other facilitators are pointing, and the shared mural broadcasts placements and drags in real time.</p>
<p>Microsoft Foundry and Microsoft Agent Framework power the AI capabilities: recommendation drafting, Discovery Card suggestions, evidence-quality checks, Frame drafting, and board clustering. Each uses the same audited, policy-gated call pattern. There are seven agents, including a Frame-drafting agent that triggers an automated critique workflow through Agent Framework.</p>
<p>Azure Container Apps hosts the API and Azure Static Web Apps hosts the frontend. Bicep deploys both from one <code>azd</code> environment, using managed identity throughout instead of connection strings or account keys.</p>
<p>Entra ID authenticates facilitators and reviewers. Anonymous workshop participants use a separate HMAC-signed, short-lived JWT scheme. It lets them vote, view, and comment, but it cannot mutate the canonical graph.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-live-mural-without-a-whiteboard-sdk">The live mural, without a whiteboard SDK<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#the-live-mural-without-a-whiteboard-sdk" class="hash-link" aria-label="Direct link to The live mural, without a whiteboard SDK" title="Direct link to The live mural, without a whiteboard SDK" translate="no">​</a></h2>
<p>I built the shared canvas (zoom and pan, drag-to-place cards, live cursors, undo, private and reveal rounds, fullscreen presenter mode) directly on Azure SignalR groups rather than reaching for a commercial whiteboard SDK.</p>
<p><img decoding="async" loading="lazy" alt="Shared card board with lanes for workshop discussion" src="https://luke.geek.nz/assets/images/card_board-1e7741acd3394d4e84bb266f4a5ed4b1.png" width="1041" height="916" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Live mural walkthrough" src="https://luke.geek.nz/assets/images/mural-walkthrough-3cb2c544886973714022038ab2bd5d88.gif" width="1317" height="896" class="img_ev3q"></p>
<p>SignalR groups are opaque. You can broadcast to a group, but you cannot enumerate its members. That constraint shaped how "start a new private round" works. In a private round, only facilitators see the cards until someone reveals them. The server tracks round state, and clients ask whether reveal mode is active on every render.</p>
<p>The zoom and pan math, the mini-map, and the zone-count overlay are all built on normalised (0..1) card coordinates that stay meaningful regardless of canvas size. That way a card placed on the big screen stays in the same relative position when someone's viewing it on a laptop or a tablet.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ai-drafts-never-decides">AI drafts, never decides<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#ai-drafts-never-decides" class="hash-link" aria-label="Direct link to AI drafts, never decides" title="Direct link to AI drafts, never decides" translate="no">​</a></h2>
<p>This is the bit that took the most tuning. The Frame-drafting agent only sees evidence explicitly captured in the workshop. It cites literal evidence IDs (never invents or paraphrases one). A deterministic C# validator rejects any output that cites something outside its authorised context before a human ever sees it.</p>
<p>Every agent shares the same advisory-only contract: never approve a gate, never claim to mutate canonical state, and always name what a human still needs to check. The Frame-draft agent can search ideation notes beyond the default recency window. It then passes its draft to a citation-groundedness critique workflow in Agent Framework. <code>frame_draft.generate</code> feeds into <code>frame_critique.generate</code> under one operation, with <code>gen_ai.usage.input_tokens</code>, <code>gen_ai.usage.output_tokens</code>, and <code>critique.concern_count: 0</code> showing that the critique stage ran without raising a concern.</p>
<p>I planted weak evidence twice, an invented "80% faster at another company, never verified" claim, to see if the agent would cite it. It did not. The draft agent cited the stronger evidence before the critique agent ran.</p>
<p><img decoding="async" loading="lazy" alt="Agentic capabilities: live AI calls across the workshop" src="https://luke.geek.nz/assets/images/agentic-capabilities-showcase-50e4067cb17ecad9974235d4c16a6c03.gif" width="1379" height="892" class="img_ev3q"></p>
<p>Also live: "Ask AI for card suggestions" on the Discovery Cards page. A second, independent agent (FoundryDiscoveryCardSuggestionAgent) reads the current persona and journey step and proposes AI-capability cards with a one-line rationale tied to that persona's actual pain point. Each is addable to the shortlist with one click.</p>
<p><img decoding="async" loading="lazy" alt="Discovery Cards catalogue with search, pinning, and board-placement actions" src="https://luke.geek.nz/assets/images/discoverycards-0e2190c6303464e89012fea6543a2863.png" width="1027" height="853" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="governance-is-part-of-the-product">Governance is part of the product<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#governance-is-part-of-the-product" class="hash-link" aria-label="Direct link to Governance is part of the product" title="Direct link to Governance is part of the product" translate="no">​</a></h2>
<p>Every model call and consequential action is checked against an allow-listed set of evaluation points and tools, in <code>evaluation-only</code> or <code>enforce</code> mode. Every decision is written to an append-only audit sink.</p>
<p>Decision review is where state actually becomes approved. A human evaluates trust and readiness, names blockers (if any), records a decision. It's the only place that flows into canonical state.</p>
<p>The audit trail lives in Application Insights alongside the regular telemetry: <code>policy.gate_name</code>, <code>policy.mode</code>, <code>policy.result</code>, and the full context of what the model saw and what it returned. You can trace from a delivery document back through the approval chain back to the original evidence.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="shipping-on-azure">Shipping on Azure<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#shipping-on-azure" class="hash-link" aria-label="Direct link to Shipping on Azure" title="Direct link to Shipping on Azure" translate="no">​</a></h2>
<p>The Container Apps API and Static Web Apps frontend deploy together from one <code>azd</code> environment. They use managed identity, with no connection strings in configuration. Bicep configures CORS, SignalR groups, and the Foundry connections.</p>
<p>I started on .NET 8 and moved to the .NET 11 preview partway through. It brought newer LINQ features and better performance on hot paths. It also meant waiting for a few NuGet packages tied to the release calendar before production.</p>
<p>I tested every feature in a browser against the deployed app as well as running unit tests. That found a client-side "Start an Ideation Round" error. Backend session creation succeeds, and Application Insights records 200 responses, but the client does not receive the message. I documented it as a separate follow-up because it does not block this workflow.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-output-is-evidence-first-delivery">The output is evidence-first delivery<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#the-output-is-evidence-first-delivery" class="hash-link" aria-label="Direct link to The output is evidence-first delivery" title="Direct link to The output is evidence-first delivery" translate="no">​</a></h2>
<p>The final artefact is a generated delivery document (a technical delivery brief) built from the approved opportunity record. It includes an AI-written summary explicitly labelled "NOT VERIFIED" with a note that a human should check it against the structured fields below.</p>
<p>It's the same drafts-never-decides contract right down to the last page. The AI suggests, the human approves.</p>
<p><img decoding="async" loading="lazy" alt="Participant join-by-code and live voting" src="https://luke.geek.nz/assets/images/participant-join-vote-7f27ffd9fe39d63caf5eebfc58356a34.gif" width="1372" height="887" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-shape-matters">Why this shape matters<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#why-this-shape-matters" class="hash-link" aria-label="Direct link to Why this shape matters" title="Direct link to Why this shape matters" translate="no">​</a></h2>
<p>Most enterprise AI features follow a simple pattern: the AI acts and the human watches. Evidence to Decision makes the human create the record and decision, while the AI suggests patterns in the evidence. This takes more effort, but it leaves an auditable decision chain six months later.</p>
<p>For an AI-envisioning workshop tool, that record is the product. The customers funding this need a defensible way to turn workshops into delivery, not a faster sticky note app.</p>
<p>I built it on Azure because Foundry, Agent Framework, SignalR, Cosmos DB, and Entra ID provide those capabilities without custom infrastructure. The platform supports the policy gates, audit trail, and identity model this workflow needs.</p>
<p>For AI features that need to stay accountable, use the same sequence: evidence → recommendation → review → decision. Let the AI suggest. Keep approval and the record with people.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="links">Links<a href="https://luke.geek.nz/azure/evidence-to-decision-ai-workshop/#links" class="hash-link" aria-label="Direct link to Links" title="Direct link to Links" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://learn.microsoft.com/azure/foundry/what-is-foundry?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft Foundry</a></li>
<li class=""><a href="https://learn.microsoft.com/agent-framework/overview/?pivots=programming-language-csharp&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft Agent Framework</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/azure-signalr/signalr-overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure SignalR Service</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/container-apps/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Container Apps</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/static-web-apps/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Static Web Apps</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Deploy Azure Monitor SLI Alerting as Code]]></title>
            <link>https://luke.geek.nz/azure/monitor-sli-alerting-as-code/</link>
            <guid>https://luke.geek.nz/azure/monitor-sli-alerting-as-code/</guid>
            <pubDate>Thu, 13 Aug 2026 03:16:54 GMT</pubDate>
            <description><![CDATA[The SLI resource has one boolean for alerting. The actual configuration lives somewhere else entirely, and once you know where, the whole thing is IaC.]]></description>
            <content:encoded><![CDATA[<p>I spent the better part of a day convinced that <a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Monitor SLI</a> alerting could not be deployed as infrastructure as code. I wrote a parallel implementation to work around it.</p>
<p>I was wrong, and the way I was wrong is the useful part.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-trap">The trap<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-trap" class="hash-link" aria-label="Direct link to The trap" title="Direct link to The trap" translate="no">​</a></h2>
<p>You create an SLI on a service group, and you want it to alert. So you read the API. <code>Microsoft.Monitor/slis</code> at <code>2025-03-01-preview</code> gives you exactly one alerting property:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">"enableAlert": true</span><br></div></code></pre></div></div>
<p>That is the whole surface. No threshold, no lookback window, no severity, and no action group. I checked the TypeSpec in <a href="https://github.com/Azure/azure-rest-api-specs" target="_blank" rel="noopener noreferrer" class=""><code>azure-rest-api-specs</code></a>, all seven files. I checked every API version the resource provider reports. There is only one. I checked whether a newer version existed. It does not.</p>
<p>Then I set <code>enableAlert: true</code> on a badly breaching SLI, with attainment at 79.9% against a 99% target and the error budget long since exhausted, and waited. No alert appeared in Alerts Management after fifteen minutes.</p>
<p>I was starting to think that the feature was portal-only and could not be expressed as code, which was weird as the portal is essentially an ARM (Azure Resource Manager) client. That conclusion was wrong.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-portal-actually-does">What the portal actually does<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#what-the-portal-actually-does" class="hash-link" aria-label="Direct link to What the portal actually does" title="Direct link to What the portal actually does" translate="no">​</a></h2>
<p>The portal's Baseline + Alert tab offers fast burn rate, slow burn rate, and action group selection. Since the SLI API has none of those fields, I captured what the portal sends when you select Save.</p>
<p>It updates the SLI, at the same API version I was already using, but only to flip <code>enableAlert</code>. Then it creates three ordinary <code>Microsoft.Insights/metricAlerts</code> resources in the workload resource group:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">&lt;sliName&gt; baseline alert</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">&lt;sliName&gt; fast burn alert</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">&lt;sliName&gt; slow burn alert</span><br></div></code></pre></div></div>
<p>Those resources use <code>api-version=2024-03-01-preview</code>, with <code>odata.type: Microsoft.Azure.Monitor.PromQLCriteria</code>. They scope the query to the Azure Monitor Workspace (<code>targetResourceType: microsoft.monitor/accounts</code>), not to the SLI.</p>
<!-- -->
<p><code>enableAlert</code> is a display flag. The alerts are the mechanism, and they are entirely ordinary ARM resources. Nothing private is involved. The undocumented part is the relationship between the two resource types.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-queries">The queries<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-queries" class="hash-link" aria-label="Direct link to The queries" title="Direct link to The queries" translate="no">​</a></h2>
<p>The alerts read the SLI's own emitted metrics. The SLI lists them under <code>destinationMetrics</code> as <code>&lt;sli&gt;:Good</code>, <code>&lt;sli&gt;:Total</code>, and <code>&lt;sli&gt;:Value</code>.</p>
<p>For a request-based SLI, the baseline rule checks attainment against the target:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") ({"availability:good"})</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") ({"availability:total"})</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) * 100 &lt; 99</span><br></div></code></pre></div></div>
<p>The burn-rate rule divides the observed error ratio by the error budget:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  (</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") (increase({"availability:total"}[15m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    -</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") (increase({"availability:good"}[15m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  (</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") (increase({"availability:total"}[15m])) * (1 - 0.99)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) &gt; 14</span><br></div></code></pre></div></div>
<p>Two details are easy to get wrong.</p>
<p>First, the correct function is <code>increase()</code>, not <code>sum_over_time()</code>. This corrects what I originally put in the post. The SLI publishes <code>:good</code> and <code>:total</code> as cumulative totals over the compliance window. They are not counters in the reset-on-restart sense, but they are monotonically increasing.</p>
<p>Early on, <code>rate()</code> against a freshly created SLI returned an empty result. I concluded from that single data point that the series was incompatible with the rate family of PromQL functions. It was not. The likely cause was too few samples. Rate-family functions need at least two samples spanning enough of the window to extrapolate from, and a new SLI cannot provide that.</p>
<p>After the series had been running for hours, <code>rate()</code> and <code>increase()</code> returned normal, consistent values. <code>sum_over_time(x[15m])</code> sums fifteen cumulative snapshots of an ever-growing total. That gives you a lookback-averaged cumulative ratio, not the error rate during those fifteen minutes.</p>
<p>Measured live, a total outage held burn rate at about 3.5 against a 14x threshold for more than five minutes. A fault confined to one SLI partition took 49 minutes to cross the same threshold. With <code>increase()</code>, the replay crossed at T+5 minutes, and a fresh live fault fired the alert at 10m06s end to end. That includes the mandatory <code>for: PT5M</code> sustained-condition window after the metric crossed the threshold.</p>
<p>If a PromQL function returns an empty result, that means the query has insufficient data at that point. It does not mean the data shape is incompatible. Retest after the series has real volume.</p>
<p>Second, <code>sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE")</code> is deliberate. The label does not exist, so the aggregation removes no real dimensions. A partitioned SLI still alerts per partition. It is the portal's own idiom, and copying it avoids changing the alert's scope accidentally.</p>
<p>The metric names also need quoted-selector syntax: <code>{"availability:good"}</code> rather than a bare identifier, because the names contain <code>:</code> and <code>-</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="burnrate-exists-but-is-not-published"><code>Burnrate</code> exists, but is not published<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#burnrate-exists-but-is-not-published" class="hash-link" aria-label="Direct link to burnrate-exists-but-is-not-published" title="Direct link to burnrate-exists-but-is-not-published" translate="no">​</a></h2>
<p>Partway through the investigation I found <code>Burnrate</code> declared as a first-class sampling type in <code>kqlmQueryResult.tsp</code>, alongside <code>Good</code>, <code>Total</code>, <code>Uptime</code>, <code>Downtime</code>, and <code>Value</code>. That looked like the answer. The service computes burn rate itself, so surely there must be a metric to alert on.</p>
<p>There is not.</p>
<p><code>kqlmQueryResult.tsp</code> describes the SLI query result model, not the metric contract. The live workspace publishes <code>:good</code>, <code>:total</code>, and <code>:value</code> for request-based SLIs, plus <code>:uptime</code>, <code>:downtime</code>, and <code>:value</code> for window-based SLIs. It does not publish <code>:burnrate</code>. Even with <code>enableAlert: true</code>, <code>destinationMetrics</code> lists only Good, Total, and Value.</p>
<p>The service computes burn rate internally and exposes it through its query surface, but does not publish it as a metric that an alert can read. The alert rules therefore calculate burn rate from <code>:good</code> and <code>:total</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="four-errors-between-here-and-a-working-deployment">Four errors between here and a working deployment<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#four-errors-between-here-and-a-working-deployment" class="hash-link" aria-label="Direct link to Four errors between here and a working deployment" title="Direct link to Four errors between here and a working deployment" translate="no">​</a></h2>
<p>Each of these took a deployment cycle to find.</p>
<p><code>The template language function 'mul' expects its first parameter to be of type 'Integer'.</code></p>
<p>I was calculating the baseline threshold as <code>json(sloTarget) * 100</code> to turn <code>0.99</code> into <code>99</code>. ARM's <code>mul</code> is integer-only. Carry the percentage as its own parameter.</p>
<p><code>Query-based alert rule doesn't support 'global' location.</code></p>
<p>Classic metric alerts are global resources, so every example you have copied probably sets <code>location: 'global'</code>. A PromQL-based alert is regional. Set <code>location</code> to the workload's region.</p>
<p><code>Query-based metric alert rules need a managed identity. Make sure it's configured.</code></p>
<p>Add <code>identity: { type: 'SystemAssigned' }</code>.</p>
<p>Then, silently, nothing evaluates. That identity needs Monitoring Data Reader (<code>b0d8363b-8ddd-447d-831f-62ca05bff136</code>) on the Azure Monitor Workspace it queries. The rule deploys cleanly without the role assignment.</p>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>danger</div><div class="admonitionContent_BuS1"><p>The managed identity and its Monitoring Data Reader role are part of the alert's working configuration. A successful ARM deployment does not prove that the query can evaluate.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bicep">The Bicep<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-bicep" class="hash-link" aria-label="Direct link to The Bicep" title="Direct link to The Bicep" translate="no">​</a></h2>
<p>This is the important part of the resource. The full implementation is in <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/infra/modules/sli-native-alerts.bicep" target="_blank" rel="noopener noreferrer" class=""><code>infra/modules/sli-native-alerts.bicep</code></a>.</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">resource</span><span class="token plain"> fastBurnAlert </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Insights/metricAlerts@2024-03-01-preview'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">sliName</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)"> fast burn alert'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">location</span><span class="token operator">:</span><span class="token plain"> location          </span><span class="token comment" style="color:rgb(98, 114, 164)">// NOT 'global'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">identity</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">type</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'SystemAssigned'</span><span class="token plain">    </span><span class="token comment" style="color:rgb(98, 114, 164)">// required, and needs Monitoring Data Reader on the AMW</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">properties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">enabled</span><span class="token operator">:</span><span class="token plain"> </span><span class="token boolean">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">severity</span><span class="token operator">:</span><span class="token plain"> </span><span class="token number">1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">targetResourceType</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'microsoft.monitor/accounts'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">scopes</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"> azureMonitorWorkspaceResourceId </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">evaluationFrequency</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'PT1M'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">criteria</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">'odata.type'</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Azure.Monitor.PromQLCriteria'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">allOf</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'SliAlertCriterion'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">query</span><span class="token operator">:</span><span class="token plain"> burnRateQuery</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">criterionType</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'StaticThresholdCriterion'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">failingPeriods</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">for</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'PT5M'</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">actions</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> actionGroupId</span><span class="token operator">:</span><span class="token plain"> pageActionGroupResourceId </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">customProperties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">serviceGroupId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">sliId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;/providers/Microsoft.Monitor/slis/</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">sliName</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">alertKind</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'fast-burn-rate'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">burnRate</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'14'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">lookback</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'15m'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p><code>customProperties</code> is not decoration. The portal uses that shape to reconstruct its Baseline + Alert UI from the alert resources. If you want alerts deployed from Bicep to render correctly in the SLI blade, reproduce the shape.</p>
<p>The complete relationship looks like this:</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-native-path-matters">Why the native path matters<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#why-the-native-path-matters" class="hash-link" aria-label="Direct link to Why the native path matters" title="Direct link to Why the native path matters" translate="no">​</a></h2>
<p>Because I believed SLI alerting was not IaC-able, I built multi-window burn-rate alerting myself in <code>Microsoft.AlertsManagement/prometheusRuleGroups</code>, recomputing the error ratio from raw application counters.</p>
<p>That parallel implementation drifted in the dangerous direction.</p>
<p>My PromQL summed across all three service tiers, so one user request counted once per hop and inflated the denominator roughly threefold. It also left <code>/healthz</code> and <code>/readyz</code> in the denominator, even though those endpoints never fail. Both errors pushed the ratio down, making the alert less sensitive than the SLO it was meant to enforce.</p>
<p>Neither object looked wrong in isolation. The SLI was correct. The alert rule was syntactically valid and fired in testing, because the test fault was a total outage and a total outage trips almost anything. I found the problem by measuring detection latency before and after correcting the selectors. The same outage went from 9m47s to 5m20s.</p>
<p>Alerts derived from the SLI's own published output cannot drift from the SLO they enforce. There is no second implementation to disagree with. That is a stronger property than saving a few lines of Bicep.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-id-tell-microsoft">What I'd tell Microsoft<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#what-id-tell-microsoft" class="hash-link" aria-label="Direct link to What I'd tell Microsoft" title="Direct link to What I'd tell Microsoft" translate="no">​</a></h2>
<p>The capability is good. The burn-rate formula is right, the multi-window design is right, and the portal experience is clean.</p>
<p>The gap is that nothing points from the SLI resource to the alert resources. The <code>enableAlert</code> boolean actively misleads: it looks like the alerting switch, and setting it does nothing observable by itself.</p>
<p>A line in the SLI documentation saying that alert configuration is stored as <code>Microsoft.Insights/metricAlerts</code> resources with <code>PromQLCriteria</code> would have saved me a day and a wrong-headed workaround. The four deployment requirements would also make a useful quickstart:</p>
<ul>
<li class="">ARM <code>mul</code> expects an integer</li>
<li class="">PromQL metric alerts need the workload's regional location</li>
<li class="">PromQL metric alerts need a managed identity</li>
<li class="">That identity needs Monitoring Data Reader on the workspace</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-lesson-id-keep">The lesson I'd keep<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-lesson-id-keep" class="hash-link" aria-label="Direct link to The lesson I'd keep" title="Direct link to The lesson I'd keep" translate="no">​</a></h2>
<p>I read the spec, tested the one field it exposed, and concluded that a feature did not exist. The spec was accurate. My inference from it was not.</p>
<p>When a portal can do something an API apparently cannot, the portal is still an ARM client. Twenty seconds in the network tab would have told me what a day of reading TypeSpec did not.</p>
<p>Check what the product does before concluding what it cannot.</p>
<p>The full Bicep for this alerting layer, and the rest of the showcase it belongs to, is public in <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p>
<p>Hopefully this article saves you the day I spent looking in the wrong resource type.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">AzureSLI-AzureSREAgent repository</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/infra/modules/sli-native-alerts.bicep" target="_blank" rel="noopener noreferrer" class="">Native SLI alert implementation</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent#architecture" target="_blank" rel="noopener noreferrer" class="">AzureSLI-AzureSREAgent architecture and reliability chain</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/docs/demo-runbook.md" target="_blank" rel="noopener noreferrer" class="">AzureSLI-AzureSREAgent deployment and demo runbook</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/docs/why-slos-not-just-alerts.md" target="_blank" rel="noopener noreferrer" class="">Why SLOs are not just alerts</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Azure Monitor SLIs on Managed Prometheus: the bits the docs don't tell you]]></title>
            <link>https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/</link>
            <guid>https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/</guid>
            <pubDate>Thu, 13 Aug 2026 02:58:13 GMT</pubDate>
            <description><![CDATA[Root causes and workarounds for creating Azure Monitor Service Level Indicators against Managed Prometheus metrics with Bicep and the ARM API.]]></description>
            <content:encoded><![CDATA[<p>Azure Monitor now does SLIs and SLOs natively, with error budgets and burn rate alerting, which is good news if you have been hand-rolling multi-window burn rate rules out of metric alerts for years. I have been building a demo that pairs Azure Monitor SLIs with the Azure SRE Agent on AKS, all deployed with the Azure Developer CLI, and I wanted every part of it in infrastructure as code.</p>
<p>Getting the infrastructure up was the easy part. Getting a single SLI created against Managed Prometheus metrics took considerably longer, and almost none of the reasons were in the documentation. This post is the list of things I wish I had known before I started, in the order they bit me.</p>
<p>If you only read one line: <strong>the metric namespace for Managed Prometheus metrics is <code>customdefault</code>, not <code>prometheus</code>.</strong> That one value cost me the most time by a wide margin.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-setup">The setup<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#the-setup" class="hash-link" aria-label="Direct link to The setup" title="Direct link to The setup" translate="no">​</a></h2>
<p>A three-tier app on AKS, instrumented with the Prometheus client, scraped by Managed Prometheus into an Azure Monitor Workspace (AMW). The SLI reads <code>http_requests_total</code> and compares good requests (status code below 500) against total requests. Standard availability SLI, nothing exotic.</p>
<p>Worth knowing up front, because it shapes everything else:</p>
<ul>
<li class="">An SLI is an <strong>extension resource on a tenant-scoped service group</strong>, at <code>/providers/Microsoft.Management/serviceGroups/{sg}/providers/Microsoft.Monitor/slis/{name}</code>. That is not the scope an <code>azd</code> Bicep deployment targets, so in practice you apply SLIs with a direct ARM call rather than from your main template.</li>
<li class="">The resource type is <code>Microsoft.Monitor/slis</code>, and at the time of writing the only published API version is <code>2025-03-01-preview</code>, even though the SLI/SLO feature itself is GA.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-1-the-metric-namespace-is-customdefault">Gotcha 1: the metric namespace is <code>customdefault</code><a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-1-the-metric-namespace-is-customdefault" class="hash-link" aria-label="Direct link to gotcha-1-the-metric-namespace-is-customdefault" title="Direct link to gotcha-1-the-metric-namespace-is-customdefault" translate="no">​</a></h2>
<p>This is the big one. Every Microsoft example I could find uses an MDM style metric namespace, and the obvious guess for Managed Prometheus metrics is <code>prometheus</code>, especially since the generated query text shows <code>metricNamespace("prometheus")</code> quite happily when you supply it.</p>
<p>Here is the trap. With <code>prometheus</code>, the metric name resolves without complaint, so you get no "metric not found" error. What you get instead is one of these two, depending on whether you declared any dimensions:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">QueryErrorCE2001: Name 'service' does not exist in current context</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">NoPartitioningDimension: Query metadata returned no partitioning dimension,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">for account: mac_..., metricName: http_requests_total, metricNamespace: prometheus</span><br></div></code></pre></div></div>
<p>Both of those point you at dimensions, which is where I spent hours. I checked the Prometheus endpoint and confirmed all 17 labels were present and queryable. I checked the portal, and the SLI blade happily listed <code>service</code>, <code>namespace</code>, <code>status_code</code>, <code>path</code>, and the rest in its dimension picker. Everything said the dimensions existed, and the API insisted they did not.</p>
<p>The dimensions were fine. The namespace was wrong.</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"signalSourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"A"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"metricNamespace"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"customdefault"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"metricName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http_requests_total"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"sourceAmwAccountResourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"/subscriptions/.../providers/microsoft.monitor/accounts/&lt;amw&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"sourceAmwAccountManagedIdentity"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"/subscriptions/.../userAssignedIdentities/&lt;identity&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"filters"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"dimensionName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"status_code"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"operator"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"notstartswith"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"value"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"5"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"spatialAggregation"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"Sum"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"dimensions"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"service"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"temporalAggregation"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"Average"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>Change <code>prometheus</code> to <code>customdefault</code> and the same request that had been failing for hours returns <code>provisioningState: Succeeded</code>.</p>
<p>Note also that an SLI needs <strong>at least one partitioning dimension</strong>. An empty <code>dimensions</code> array fails with <code>NoPartitioningDimension</code>, so pick something meaningful (I use <code>service</code>).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-2-the-identity-roles-go-on-the-dcr-not-the-workspace">Gotcha 2: the identity roles go on the DCR, not the workspace<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-2-the-identity-roles-go-on-the-dcr-not-the-workspace" class="hash-link" aria-label="Direct link to Gotcha 2: the identity roles go on the DCR, not the workspace" title="Direct link to Gotcha 2: the identity roles go on the DCR, not the workspace" translate="no">​</a></h2>
<p>The documented prerequisites say the SLI identity needs <strong>Monitoring Reader</strong> on the source workspace, and <strong>Monitoring Reader</strong> plus <strong>Monitoring Metrics Publisher</strong> on the destination workspace's default data collection rule (DCR).</p>
<p>I read that, and assigned both roles on the Azure Monitor Workspace resource. That looks right, it passes review, and it fails at SLI creation with:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[DestinationAmwAccountAccessValidator] Access denied to target resource</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">/subscriptions/.../resourceGroups/MA_&lt;amw&gt;_&lt;region&gt;_managed/providers/Microsoft.Insights/dataCollectionRules/&lt;amw&gt;</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">for identity /subscriptions/.../userAssignedIdentities/&lt;identity&gt;</span><br></div></code></pre></div></div>
<p>The default DCR is not in your resource group. It lives in the workspace's <strong>managed</strong> resource group, named <code>MA_&lt;amw-name&gt;_&lt;region&gt;_managed</code>, and it shares the workspace's name.</p>
<p><img decoding="async" loading="lazy" alt="Where the SLI identity&amp;#39;s roles actually need to go: assigning Monitoring Reader and Monitoring Metrics Publisher to the Azure Monitor Workspace resource itself fails SLI creation, the roles need to go on the default DCR inside Azure&amp;#39;s own automatically-created managed resource group instead" src="https://luke.geek.nz/assets/images/gotcha2-dcr-rbac-7f4c56d63188e292c3d3296b96a970c2.svg" width="1166" height="487" class="img_ev3q"></p>
<p>In Bicep you need a separate module scoped to that resource group:</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">module</span><span class="token plain"> sliDcrRbac </span><span class="token string" style="color:rgb(255, 121, 198)">'modules/sli-dcr-rbac.bicep'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">scope</span><span class="token operator">:</span><span class="token plain"> az</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">resourceGroup</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'MA_</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">amwName</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">_</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">location</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">_managed'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'sli-dcr-rbac'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">params</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">azureMonitorWorkspaceName</span><span class="token operator">:</span><span class="token plain"> amwName</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">sliIdentityPrincipalId</span><span class="token operator">:</span><span class="token plain"> sliIdentity</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">outputs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">principalId</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>with the DCR referenced as <code>existing</code> inside that module, and role assignments scoped to it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-3-the-preview-call-is-useless-for-debugging-the-create-call-is-excellent">Gotcha 3: the preview call is useless for debugging, the create call is excellent<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-3-the-preview-call-is-useless-for-debugging-the-create-call-is-excellent" class="hash-link" aria-label="Direct link to Gotcha 3: the preview call is useless for debugging, the create call is excellent" title="Direct link to Gotcha 3: the preview call is useless for debugging, the create call is excellent" translate="no">​</a></h2>
<p>There is a <code>sliSignalPreview</code> action that the portal's <strong>Validate</strong> button uses. It is tempting to gate your deployment on it, which is exactly what I did, and it was a mistake.</p>
<p>For essentially every failure, <code>sliSignalPreview</code> returns this:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"code"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"MalformedStructureError"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"message"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"The request properties payload is not in the correct format."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>No detail, no field name, nothing. Meanwhile the actual create call (<code>PUT .../slis/{name}</code>) returns the generated query, the validator that rejected it, and the offending identifier by name. Same inputs, completely different diagnostic value.</p>
<p>So do not gate creation on a successful preview. Attempt the create and read what comes back. My script now runs the preview as advisory output only.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-4-the-real-error-is-often-in-the-portal-notification-pane">Gotcha 4: the real error is often in the portal notification pane<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-4-the-real-error-is-often-in-the-portal-notification-pane" class="hash-link" aria-label="Direct link to Gotcha 4: the real error is often in the portal notification pane" title="Direct link to Gotcha 4: the real error is often in the portal notification pane" translate="no">​</a></h2>
<p>SLI creation runs several validators in sequence, and each one masks the next. When it fails in the portal, the blade shows a generic validation banner and resets the wizard back to Basics, which tells you nothing.</p>
<p>The full error, including the validator name, shows up in the <strong>notification pane</strong> (the bell icon). The <code>DestinationAmwAccountAccessValidator</code> failure above was visible nowhere else: not in the blade, and not in the response to the preview call.</p>
<p>If both your API calls and the portal appear to fail identically, check the notifications before you conclude anything about the platform. I twice decided this was a platform limitation and twice I was wrong.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-5-prerequisites-that-fail-as-generic-errors">Gotcha 5: prerequisites that fail as generic errors<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-5-prerequisites-that-fail-as-generic-errors" class="hash-link" aria-label="Direct link to Gotcha 5: prerequisites that fail as generic errors" title="Direct link to Gotcha 5: prerequisites that fail as generic errors" translate="no">​</a></h2>
<p>Two more that produce contentless errors rather than saying what is missing.</p>
<p><strong>The service group needs a default Azure Monitor Workspace.</strong> Until Monitor settings are configured on the service group, every SLI call fails. The portal path is service group, then Monitoring, then Monitor settings. Microsoft documents the subscription scoped association:</p>
<div class="language-http codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-http codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT https://management.azure.com/subscriptions/&lt;sub&gt;/providers/microsoft.monitor/settings/default?api-version=2025-06-03-preview</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">{ "properties": { "defaultAzureMonitorWorkspace": "&lt;amw resource id&gt;" } }</span><br></div></code></pre></div></div>
<p>What is not documented, but works, is the same settings resource at <strong>service group scope</strong>:</p>
<div class="language-http codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-http codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT https://management.azure.com/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;/providers/microsoft.monitor/settings/default?api-version=2025-06-03-preview</span><br></div></code></pre></div></div>
<p>That one returns a proper resource with an etag, and it means you can keep the whole thing in code instead of clicking through the portal. Treat it as undocumented and re-check it on each new API version.</p>
<p><strong>The metrics have to exist first.</strong> An SLI requires its input metric and its partitioning dimensions to already be present in Managed Prometheus at creation time. Create the SLI before the series materialises and validation fails. In a fresh environment that is a real wait: allow for the scrape interval plus two to three minutes of ingestion lag before you try. My deployment script polls the Prometheus endpoint for the metric name and only then creates the SLI.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-6-enablealert-looks-like-the-switch-and-it-lies">Gotcha 6: <code>enableAlert</code> looks like the switch, and it lies<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-6-enablealert-looks-like-the-switch-and-it-lies" class="hash-link" aria-label="Direct link to gotcha-6-enablealert-looks-like-the-switch-and-it-lies" title="Direct link to gotcha-6-enablealert-looks-like-the-switch-and-it-lies" translate="no">​</a></h2>
<p>At <code>2025-03-01-preview</code>, the SLI resource exposes a single boolean, <code>enableAlert</code>. The fast burn and slow burn thresholds and the action group selection that the portal offers on the Baseline + Alert tab have no representation anywhere in the API surface, and there is no SLI specific alert type under <code>Microsoft.AlertsManagement</code> or <code>Microsoft.Insights</code> either. Read the API cold and the reasonable conclusion is that SLI alerting is portal-only, a boolean with nowhere for the real configuration to live.</p>
<p>I believed that for the better part of a day, and started building a parallel implementation in <code>Microsoft.AlertsManagement/prometheusRuleGroups</code> to work around it. Wrong. <code>enableAlert</code> is a display flag. The real configuration lives in ordinary <code>Microsoft.Insights/metricAlerts</code> resources, using <code>Microsoft.Azure.Monitor.PromQLCriteria</code>, that read the SLI's own published <code>:good</code>/<code>:total</code> metrics, and every bit of it deploys from Bicep. I cover the discovery, the queries, and the working resource definition in a companion post: <a class="" href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/">Azure Monitor SLI alerting really is deployable as code, just not where you'd look</a>.</p>
<p>The parallel <code>prometheusRuleGroups</code> implementation I built first is worth knowing about as a cautionary tale, not a recommendation. My selectors summed across all three service tiers and left health-check paths in the denominator, both of which quietly made the alert less sensitive than the SLO it was meant to enforce. Deriving alerts from the SLI's own output instead of recomputing them is the actual fix, and it's the pattern I'd ship.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-7-the-burn-rate-windows-everybody-quotes-assume-a-30-day-budget">Gotcha 7: the burn rate windows everybody quotes assume a 30 day budget<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-7-the-burn-rate-windows-everybody-quotes-assume-a-30-day-budget" class="hash-link" aria-label="Direct link to Gotcha 7: the burn rate windows everybody quotes assume a 30 day budget" title="Direct link to Gotcha 7: the burn rate windows everybody quotes assume a 30 day budget" translate="no">​</a></h2>
<p>This one is not an Azure quirk at all, which is exactly why it is worth writing down. It cost me more time than any of the API problems above, and it will be sitting in a lot of SLO implementations right now.</p>
<p>Every reference for multi window burn rate alerting gives you the same table: fast burn at 14.4x over 1h and 5m, slow burn at 6x over 6h and 30m. What almost nobody carries along with it is the precondition. Those windows assume a <strong>30 day</strong> error budget.</p>
<p>My demo uses a 1-day compliance window, because on 30 days a fresh error budget barely moves and there is nothing to show. I copied the canonical windows in anyway.</p>
<p>The failure mode is not what you would expect. It is not that the alert is noisy, or that it fires late. It is that the <strong>long window is the rate limiter</strong>, and it makes partial faults invisible. A rolling 1h average starting from zero has to be physically dragged up to the threshold, and how long that takes depends on how big the fault is. Against a 7.2% threshold:</p>
<table><thead><tr><th>Injected error rate</th><th>Time for the 1h window to cross</th></tr></thead><tbody><tr><td>95%, a total outage</td><td>about 4.5 minutes</td></tr><tr><td>20%</td><td>about 22 minutes</td></tr><tr><td>8%</td><td>about 54 minutes</td></tr></tbody></table>
<p>Read that table again, because the shape of it is the point. The alert fires promptly for a total outage and effectively never fires for a realistic partial degradation.</p>
<p>And here is why it survives review: the fault everyone tests with is a total outage. Kill the pod, watch the page arrive, tick the box. The configuration passes every test you throw at it while being unable to detect the degradations you are most likely to actually experience.</p>
<p>I only noticed because I wanted a better demo. A crashing pod is a weak SLO story, since Kubernetes already tells you about a crashing pod. What I wanted was the fault where every pod is <code>Running</code> with zero restarts and users are still failing, because that is the entire argument for SLIs on one screen. When I injected a 20% error rate, nothing fired. My first instinct was that the fault was too small. It was not, the window was too long.</p>
<p>Shortening the fast burn long window from 1h to 15m fixed it. Measured on the live environment: a 20% error rate now pages at Sev1 in <strong>8 minutes 36 seconds</strong>, which is faster than the total outage managed under the old windows.</p>
<p>If you take one thing from this post, take this: <strong>derive your long window from your compliance window, and test with a partial fault.</strong> Compute <code>long_window x threshold / expected_error_rate</code> and ask whether that many minutes is acceptable for the smallest degradation you care about. Copying 1h/5m onto a 1-day budget is as wrong as copying it onto a 1-hour budget, and it fails silently in the direction of missing real incidents.</p>
<p>A related trap while you are testing: a long window keeps carrying the previous incident's errors well after recovery, so a slow burn rule can still be Fired from your <em>last</em> test run. I nearly recorded a stale Sev2 as a successful detection. Filter alerts by <code>startDateTime</code> against your injection time rather than trusting what is on the screen.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-8-the-signal-model-is-much-more-capable-than-the-examples-suggest">Gotcha 8: the signal model is much more capable than the examples suggest<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-8-the-signal-model-is-much-more-capable-than-the-examples-suggest" class="hash-link" aria-label="Direct link to Gotcha 8: the signal model is much more capable than the examples suggest" title="Direct link to Gotcha 8: the signal model is much more capable than the examples suggest" translate="no">​</a></h2>
<p>Every worked example I found uses a single signal source and a <code>signalFormula</code> of <code>"A"</code>. That makes the model look thin. It is not, and I built three noticeably better SLIs once I read the TypeSpec instead of the examples.</p>
<p><strong><code>signalFormula</code> combines multiple sources.</strong> Give each source a distinct <code>signalSourceId</code> and reference them in the formula. Dividing a histogram's <code>_sum</code> by its <code>_count</code> gives you mean latency computed inside the SLI, with no recording rule and no change to your app:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token property">"signals"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"signalFormula"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"A / B"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"signalSources"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"signalSourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"A"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"metricName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http_request_duration_seconds_sum"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"signalSourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"B"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"metricName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http_request_duration_seconds_count"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p><strong><code>in</code> and <code>notin</code> take a <code>^^</code> delimited string, not a JSON array.</strong> This is documented only in a doc comment on the <code>ConditionOperator</code> union, and it is useful rather than trivia:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"dimensionName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"service"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"operator"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"in"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"value"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"frontend^^api^^upstream-auth-service"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>That is one SLI spanning a whole call chain instead of three separate ones. It matters for design, not just syntax. An edge only availability SLI stays green when something fails deep in the chain and the edge masks it with a cache or a fallback. A journey scoped one does not.</p>
<p><strong><code>windowUptimeCriteria.target</code> is a float</strong>, so sub-second thresholds work fine, and window-based SLIs are worth having alongside request-based ones rather than instead of them. The request-based one answers "what share of requests were good". The window-based one answers "how much of the time was the service bad", and it is the one that stays meaningful in a quiet period where a handful of slow requests can swing a ratio.</p>
<p>One caveat to close that loop, and it is the mistake I made. Because burn rate alerting lives in a parallel system rather than on the SLI resource, nothing keeps the two in step. I ended up with four SLIs, two availability and two latency, and burn rate rules for availability only. The latency SLIs charted perfectly and paged nobody. The list looks symmetrical, which is precisely why nobody catches it. Enumerate your SLIs and your alert rules side by side.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-9-enabling-the-monitoring-add-on-does-not-enable-monitoring">Gotcha 9: enabling the monitoring add-on does not enable monitoring<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-9-enabling-the-monitoring-add-on-does-not-enable-monitoring" class="hash-link" aria-label="Direct link to Gotcha 9: enabling the monitoring add-on does not enable monitoring" title="Direct link to Gotcha 9: enabling the monitoring add-on does not enable monitoring" translate="no">​</a></h2>
<p>This is the one I am most annoyed about, because I hit the same failure mode twice in the same build and still did not recognise it the second time.</p>
<p>Managed Prometheus first. Setting <code>azureMonitorProfile.metrics.enabled = true</code> in Bicep gets you the metrics agent and no metrics. You also need a data collection endpoint, a data collection rule, and an association to the cluster. I knew this, I built it, it worked.</p>
<p>Then Container Insights. <code>omsAgentEnabled: true</code> with the right workspace id. Addon reports enabled. The <code>ama-logs</code> pods run happily. And every single Container Insights table was empty for the entire life of the cluster:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">union withsource=T *</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where TimeGenerated &gt; ago(24h)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| summarize n=count() by T</span><br></div></code></pre></div></div>
<p>No rows. Not "a bit thin", not "one table missing". Nothing, anywhere, ever. Same root cause as Prometheus: the enable flag provisions the agent, and a <strong>data collection rule associated to the cluster</strong> is what gives it a job. <code>az aks enable-addons</code> creates that rule for you. Bicep does not.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">az monitor data-collection rule association list </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--resource</span><span class="token plain"> </span><span class="token operator">&lt;</span><span class="token plain">cluster-id</span><span class="token operator">&gt;</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--query</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"[].name"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-o</span><span class="token plain"> tsv</span><br></div></code></pre></div></div>
<p>Expect one association per collection path. I had exactly one, the Prometheus one, and no error anywhere in the portal, the CLI, the addon status, or the pod logs to suggest anything was missing.</p>
<p>Take the DCR shape and the stream list from the canonical onboarding template in <a href="https://github.com/microsoft/Docker-Provider/blob/ci_prod/scripts/onboarding/aks/onboarding-using-msi-auth/existingClusterOnboarding.json" target="_blank" rel="noopener noreferrer" class=""><code>microsoft/Docker-Provider</code></a> rather than writing it from memory. The stream names are exact strings and there are twelve of them.</p>
<p>The knock-on effect is worth stating: my workbook had never had data, and my write-up claimed the SRE Agent "cites the AKS pod event as root-cause evidence". It cannot cite a pod event from an empty table. I had asserted that from the design rather than from an observation.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-10-an-empty-panel-and-an-impossible-query-look-identical">Gotcha 10: an empty panel and an impossible query look identical<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-10-an-empty-panel-and-an-impossible-query-look-identical" class="hash-link" aria-label="Direct link to Gotcha 10: an empty panel and an impossible query look identical" title="Direct link to Gotcha 10: an empty panel and an impossible query look identical" translate="no">​</a></h2>
<p>Once Container Insights was actually collecting, one workbook panel stayed empty. The query was fine, in the sense that it parsed and ran and returned zero rows:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">InsightsMetrics</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where Namespace == 'container.azm.ms/memory'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where Name == 'memoryWorkingSetBytes'</span><br></div></code></pre></div></div>
<p>There is no <code>container.azm.ms/memory</code> namespace. <code>InsightsMetrics</code> carries node, disk and kube-state metrics. Container CPU and memory live in the <strong><code>Perf</code></strong> table under <code>ObjectName == 'K8SContainer'</code>, and to get from there to a namespace you have to join back to <code>KubePodInventory</code>, because <code>Perf.InstanceName</code> is <code>&lt;clusterResourceId&gt;/&lt;podUID&gt;/&lt;containerName&gt;</code>:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">let podMap = KubePodInventory</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where Namespace == 'payment'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| distinct ContainerName, Name;</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Perf</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where ObjectName == 'K8SContainer' and CounterName == 'memoryWorkingSetBytes'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| extend ContainerName = strcat(tostring(split(InstanceName, '/')[-2]), '/', tostring(split(InstanceName, '/')[-1]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| join kind=inner podMap on ContainerName</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| summarize WorkingSetMiB = avg(CounterValue) / 1024 / 1024 by bin(TimeGenerated, 1m), Name</span><br></div></code></pre></div></div>
<p>The lesson generalises well beyond workbooks: <strong>a query against the wrong table returns zero rows forever, and zero rows is indistinguishable from "no data yet" or "nothing is broken right now".</strong> Before you believe an empty panel, strip the filters and run a positive control:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">InsightsMetrics | summarize count() by Namespace, Name | order by count_ desc</span><br></div></code></pre></div></div>
<p>That one command would have shown me the real namespaces immediately, and it is the first thing I will run next time.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-11-service-group-membership-looks-tenant-blocked-and-is-actually-an-api-version-trap">Gotcha 11: Service Group membership looks tenant-blocked, and is actually an api-version trap<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-11-service-group-membership-looks-tenant-blocked-and-is-actually-an-api-version-trap" class="hash-link" aria-label="Direct link to Gotcha 11: Service Group membership looks tenant-blocked, and is actually an api-version trap" title="Direct link to Gotcha 11: Service Group membership looks tenant-blocked, and is actually an api-version trap" translate="no">​</a></h2>
<p>This one I got wrong, publicly and for weeks, so it is worth walking through properly.</p>
<p>Service groups are pitched as spanning resources across subscriptions, which is exactly what makes them attractive for a service-level SLO. I could not add a single member. The path, each step producing a different error:</p>
<ol>
<li class=""><code>PUT .../providers/Microsoft.Relationships/serviceGroupMember/&lt;name&gt;</code> returns <code>SubscriptionNotRegistered</code>.</li>
<li class=""><code>az provider register --namespace Microsoft.Relationships</code> succeeds.</li>
<li class="">Retry: <code>HttpRequestPayloadAPISpecValidationFailed</code>. Two things buried in the details array: the name must match <code>^[a-zA-Z0-9]{3,64}$</code>, so hyphens are rejected, and <code>sourceId</code> is required alongside <code>targetId</code>.</li>
<li class="">Retry with a valid payload: <strong><code>RelLifecycleNotEnabledForTenant</code></strong>, <em>"Relationship lifecycle callbacks are not enabled for tenant."</em></li>
</ol>
<p>Undocumented flag, no self-serve way to enable it. I recorded it as a hard limitation and designed around it.</p>
<p>It is not a limitation. <strong>Step 4 only happens on <code>2026-03-01-preview</code>.</strong></p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)"># fails: RelLifecycleNotEnabledForTenant</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT </span><span class="token punctuation" style="color:rgb(248, 248, 242)">..</span><span class="token plain">./serviceGroupMember/slisreShowcase?api-version</span><span class="token operator">=</span><span class="token number">2026</span><span class="token plain">-03-01-preview</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"properties"</span><span class="token plain">:</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"sourceId"</span><span class="token builtin class-name" style="color:rgb(189, 147, 249)">:</span><span class="token string" style="color:rgb(255, 121, 198)">"/subscriptions/&lt;sub&gt;"</span><span class="token plain">,</span><span class="token string" style="color:rgb(255, 121, 198)">"targetId"</span><span class="token builtin class-name" style="color:rgb(189, 147, 249)">:</span><span class="token string" style="color:rgb(255, 121, 198)">"&lt;sg&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># succeeds</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT </span><span class="token punctuation" style="color:rgb(248, 248, 242)">..</span><span class="token plain">./serviceGroupMember/slisreShowcase?api-version</span><span class="token operator">=</span><span class="token number">2023</span><span class="token plain">-09-01-preview</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"properties"</span><span class="token plain">:</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"targetId"</span><span class="token builtin class-name" style="color:rgb(189, 147, 249)">:</span><span class="token string" style="color:rgb(255, 121, 198)">"&lt;sg&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>The older version infers <code>sourceId</code> from the parent scope and provisions in seconds. The newer one demands <code>sourceId</code> explicitly and <em>then</em> refuses on a tenant flag. So the newest preview is the more restrictive one, which is the opposite of the assumption that sent me down this path. I had reached for the newest api-version precisely because the older one seemed to be failing, and in doing so swapped a solvable error for an unsolvable-looking one.</p>
<p>What actually misled me is subtler than the version number. Errors 1, 3 and 4 are all real and all different, so each retry felt like progress toward a wall. A single error repeated would have made me question the request; a <em>sequence</em> of distinct, plausible errors reads like a narrowing path toward a wall.</p>
<p><strong>And once it works, it is ordinary Bicep</strong>, because the relationship's parent is the <em>member</em>, not the service group:</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">targetScope</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'subscription'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">resource</span><span class="token plain"> serviceGroupMembership </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Relationships/serviceGroupMember@2023-09-01-preview'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'slisreShowcaseBicep'</span><span class="token plain">          </span><span class="token comment" style="color:rgb(98, 114, 164)">// alphanumeric only</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">properties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">targetId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">serviceGroupId</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>That is a subscription-scoped resource, so it deploys from the same template as everything else. Which produces an odd asymmetry worth knowing:</p>
<table><thead><tr><th></th><th>Parent</th><th>Deployment scope needed</th><th>In Bicep?</th></tr></thead><tbody><tr><td>Service group <strong>membership</strong></td><td>the member subscription</td><td>subscription</td><td><strong>yes</strong></td></tr><tr><td>The <strong>SLI</strong> itself</td><td>the service group</td><td>tenant</td><td>no, without RBAC at <code>/</code></td></tr></tbody></table>
<p>Same feature area, opposite answers, purely because of which end of the relationship owns the resource.</p>
<p><strong>Does membership matter?</strong> Less than the effort suggests, and the reason is worth understanding. An SLI does not reference Azure resources at all: its scope is <code>sourceAmwAccountResourceId</code> plus <code>metricNamespace</code>, <code>metricName</code>, and <strong>dimension filters</strong>. My <code>journey-availability</code> SLI spans three tiers purely because of <code>service in "frontend^^api^^upstream-auth-service"</code>. Membership gives you topology and rollup; it changes no SLI's computation.</p>
<p>What it does buy is letting something else <em>tell</em> you what composes the service, rather than you asserting it. That matters for the SRE Agent, which scopes its knowledge graph to a resource group: if a service actually spans resource groups, the agent investigates one and silently misses the rest.</p>
<p><strong>The lesson, and it is the same one as the alerting post:</strong> when an Azure preview API refuses, check whether an <em>older</em> api-version accepts. Newer previews tighten validation and add gates, and the resource provider will happily tell you which versions exist: the error at the wrong version named all of them, and I did not read it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-12-get-returns-shapes-that-put-rejects">Gotcha 12: GET returns shapes that PUT rejects<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-12-get-returns-shapes-that-put-rejects" class="hash-link" aria-label="Direct link to Gotcha 12: GET returns shapes that PUT rejects" title="Direct link to Gotcha 12: GET returns shapes that PUT rejects" translate="no">​</a></h2>
<p>Round-tripping a resource (GET it, change one field, PUT it back) is the most natural thing to try, and it fails in several places across this stack:</p>
<ul>
<li class=""><strong>SLI</strong>: the <code>identity.userAssignedIdentities</code> values from GET are rejected on PUT with <code>InvalidIdentityValues</code>. They must be empty objects (<code>{}</code>).</li>
<li class=""><strong>SRE Agent response plans</strong>: GET returns <code>isDeleted</code>, <code>documentType</code>, <code>partitionKey</code>, <code>createdAt</code>, <code>updatedAt</code>; POST rejects all five with <code>Unknown incident filter properties</code>.</li>
<li class=""><strong>SRE Agent skills and subagents</strong>: GET returns array fields as <em>stringified</em> lists (<code>"tools": "['RunAzCliReadCommands']"</code>), while PUT requires real JSON arrays. Sending back what you received yields <code>400 ... could not be converted to System.Collections.Generic.List</code>.</li>
</ul>
<p>Strip server-generated fields and rebuild write payloads from the documented shape rather than from a GET response.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-sli-correctness-traps-while-you-are-here">Two SLI correctness traps while you are here<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#two-sli-correctness-traps-while-you-are-here" class="hash-link" aria-label="Direct link to Two SLI correctness traps while you are here" title="Direct link to Two SLI correctness traps while you are here" translate="no">​</a></h2>
<p>Neither of these is an Azure quirk, they are just easy to get wrong, and both quietly produce an SLI that measures the wrong thing.</p>
<p><strong>Health probe traffic.</strong> Your scraped series includes <code>/healthz</code> and <code>/readyz</code>. Those never fail, so leaving them in the denominator dilutes the error ratio and your SLO will not move the way you expect. Filter to the user journey path.</p>
<p><strong>Multi-tier double counting.</strong> If every tier emits the same metric name, one user request is counted once per hop. Filter to the edge service, otherwise a three-tier app inflates the denominator threefold.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-actually-found-the-answers">What actually found the answers<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#what-actually-found-the-answers" class="hash-link" aria-label="Direct link to What actually found the answers" title="Direct link to What actually found the answers" translate="no">​</a></h2>
<p>Worth saying, because the method matters more than the specific values.</p>
<p>Searching the docs and Learn got me nowhere on the namespace. What found it was searching <strong>GitHub code for the literal error string</strong>, <code>DestinationAmwAccountAccessValidator</code>. That returned exactly one result, a <code>workarounds.md</code> in Toru Makabe's <a href="https://github.com/torumakabe/aks-chaos-lab" target="_blank" rel="noopener noreferrer" class="">aks-chaos-lab</a>, which documents both the DCR RBAC requirement and the metrics-must-exist-first constraint, and whose SLI Bicep module has <code>metricNamespace</code> defaulting to <code>customdefault</code>. Full credit there, that repo saved me a support case.</p>
<p>If you are stuck on an Azure error that reads like an internal validator name, put the exact string into GitHub code search before you do anything else. Somebody has usually hit it and written it down.</p>
<p>The other thing that paid off was reading the <strong>TypeSpec</strong> rather than the examples. <code>sliProperties.tsp</code> in <a href="https://github.com/Azure/azure-rest-api-specs" target="_blank" rel="noopener noreferrer" class=""><code>Azure/azure-rest-api-specs</code></a> is about 260 lines and took five minutes, and it is where the <code>^^</code> delimiter, the float target, and the multi source formula all are. The examples had led me to believe none of those existed.</p>
<p>And one anti method, since it wasted an hour. When I could not find an API to list the SRE Agent's incidents, I started guessing endpoint paths. Three of them returned HTTP 200, which felt like progress, right up until I looked at the body and found the portal's single page app HTML rather than JSON. A 200 from a SPA host means "this host serves a web app", not "this endpoint exists". If you are guessing paths, you have already left the part of the problem that is solvable by guessing.</p>
<p>What actually worked was asking what the service <strong>emits</strong> rather than what it exposes. The agent resource has a <code>logConfiguration.applicationInsightsConfiguration.appId</code>, and the agent writes an <code>IncidentActivitySnapshot</code> custom event to it on every incident transition, carrying the response plan id, the autonomy level, the handled timestamp, and the agent's written investigation summary. That answered in one KQL query what an hour of path guessing could not. When an Azure service will not give you an API, check its telemetry before you reach for its UI.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="wrap-up">Wrap up<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#wrap-up" class="hash-link" aria-label="Direct link to Wrap up" title="Direct link to Wrap up" translate="no">​</a></h2>
<p>None of this makes Azure Monitor SLIs a bad feature. The error budget and burn rate experience is useful, and having it native beats maintaining your own burn rate rules. But the preview API surface is sharp in places, and the failure modes are almost all generic errors that point away from the real cause.</p>
<p>The short version for anyone starting out:</p>
<ul>
<li class="">Use <code>customdefault</code> as the metric namespace for Managed Prometheus metrics.</li>
<li class="">Put the identity roles on the default DCR in <code>MA_&lt;amw&gt;_&lt;region&gt;_managed</code>, not on the workspace.</li>
<li class="">Debug with the create call, not <code>sliSignalPreview</code>.</li>
<li class="">Check the portal notification pane for the real error.</li>
<li class="">Configure the service group default AMW first, and wait for your metrics to land.</li>
<li class="">Don't recompute burn rate alerting yourself. Derive it from the SLI's own <code>:good</code>/<code>:total</code> metrics via <code>Microsoft.Insights/metricAlerts</code> and <code>PromQLCriteria</code>, it's IaC-able, just not where the SLI resource itself suggests.</li>
</ul>
<p>Hopefully this saves you the afternoon it cost me.</p>
<p>The full Bicep, scripts, and agent configuration for the showcase this was all found while building are public: <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Create service level indicators in Azure Monitor</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/templates/microsoft.monitor/2025-03-01-preview/slis?pivots=deployment-language-bicep&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft.Monitor/slis template reference</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/azure-monitor/aiops/issues-how-to?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Use Azure Monitor issues (default AMW association)</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/governance/service-groups/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Service groups overview</a></li>
<li class=""><a href="https://github.com/torumakabe/aks-chaos-lab" target="_blank" rel="noopener noreferrer" class="">torumakabe/aks-chaos-lab</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Designing User-Centred Reliability with Azure Monitor SLIs and the Azure SRE Agent]]></title>
            <link>https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/</link>
            <guid>https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/</guid>
            <pubDate>Thu, 13 Aug 2026 00:47:11 GMT</pubDate>
            <description><![CDATA[How to design user-centred reliability signals with Azure Monitor SLIs, Service Groups, error budgets, and Azure SRE Agent remediation for an AKS workload.]]></description>
            <content:encoded><![CDATA[<p>Most Kubernetes monitoring tells you that a pod restarted. It rarely tells you whether a customer noticed, which customers were affected, or whether the business promise is now at risk.</p>
<p>That gap is a product and service-design problem as much as a monitoring problem. The people responsible for a payment journey need to know whether checkout works, how quickly it works, and which customer segment is paying the cost of an incident. The on-call engineer needs enough evidence to choose the right first action. The business needs a trustworthy way to decide when an error budget has been spent.</p>
<p>I wanted to see whether an <a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Monitor SLI</a> could give the <a href="https://learn.microsoft.com/azure/sre-agent/overview?tabs=task&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure SRE Agent</a> enough context to investigate an <a href="https://learn.microsoft.com/azure/aks/what-is-aks?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Kubernetes Service</a> failure, rather than just react to a pod alert. I built a small payment service, chose signals around the customer journey, and deliberately broke it.</p>
<p>The result is an infrastructure-as-code deployment that runs with <code>azd up</code>. It measures availability, latency, the full request journey, the public path, and customer tiers. Azure Monitor alerts on those service-level signals, and the Azure SRE Agent investigates the incident and, when allowed, fixes it.</p>
<p>The important result is not the number of Azure resources. It is the chain from customer need to operational decision:</p>
<table><thead><tr><th>Question</th><th>Evidence</th><th>Decision it supports</th></tr></thead><tbody><tr><td>Can customers complete checkout?</td><td>Journey availability</td><td>Treat the incident as a service problem, not a pod problem</td></tr><tr><td>Who is affected?</td><td>Customer-tier dimensions</td><td>Prioritise impact and communicate with the right customers</td></tr><tr><td>Is the public entry point working?</td><td>External availability</td><td>Avoid restarting healthy workloads when DNS or TLS is broken</td></tr><tr><td>Is the budget being spent quickly?</td><td>Burn rate</td><td>Decide whether to wake someone or continue observing</td></tr><tr><td>Is a remediation safe?</td><td>Logs, metrics, RBAC, and audit evidence</td><td>Automate only actions with a bounded risk</td></tr></tbody></table>
<p>I got several things wrong. The corrections stay in the post because they expose the difference between a design that looks plausible and one that has been tested with the people, decisions, and failure modes it is meant to serve.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="who-this-is-for">Who this is for<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#who-this-is-for" class="hash-link" aria-label="Direct link to Who this is for" title="Direct link to Who this is for" translate="no">​</a></h2>
<p>This walkthrough is aimed at teams that share responsibility for a customer-facing service:</p>
<ul>
<li class="">Platform engineers who own Azure Monitor workspaces, identities, collection, and RBAC</li>
<li class="">SRE and operations teams who define SLOs, error-budget policy, and incident response</li>
<li class="">Product and engineering leaders who need reliability data expressed in terms of customer impact</li>
<li class="">Teams evaluating how much operational work an AI agent should investigate, propose, or perform</li>
</ul>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><blockquote>
<p>Before getting into the build, three terms need separating. An <strong>SLI (Service Level Indicator)</strong> is the measured number, such as "99.2% of checkout requests succeeded in the last 24 hours." An <strong>SLO (Service Level Objective)</strong> is the internal target for that number, such as "availability stays above 99% over a rolling 28 days." An <strong>SLA (Service Level Agreement)</strong> is the contractual promise to a customer, and should be looser than the SLO.</p>
</blockquote><p>Most teams have an SLA somewhere in a contract, an SLO nobody agreed on, and an SLI that's really just "whatever our dashboard happens to show."</p><p>Azure SRE Agent is Microsoft's AI agent product for exactly this problem: it watches Azure Monitor alerts, investigates using your actual logs and metrics, and, if configured to do so, takes a remediation action itself and looks for root cause if it has access to the codebase. No human has to be paged, triage the graphs, and type <code>kubectl scale</code> at 3am. The agent does the first pass, and depending on how much you trust it for a given incident class, either proposes what it would do or does it.</p><p>The outcome I wanted was an SLO breach that names which customer segment is affected, a differentiated service commitment expressed in Azure Monitor, and an AI agent that checks the other firing SLIs before it touches a workload.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="start-with-the-service-promise">Start with the service promise<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#start-with-the-service-promise" class="hash-link" aria-label="Direct link to Start with the service promise" title="Direct link to Start with the service promise" translate="no">​</a></h2>
<!-- -->
<p>The arrow from <code>slis</code> to <code>alerts</code> is the design decision I cared about. The alerts read the SLI's own published metrics, not a second implementation built from raw application counters. That keeps the alert and the SLO measuring the same customer promise.</p>
<p>This is the first design-thinking checkpoint: define the outcome before choosing the tool. “A pod is ready” is an implementation fact. “A customer can complete checkout within 500 milliseconds” is an outcome. Both are useful, but they answer different questions and should not be treated as interchangeable.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><p>The repository, including the Bicep, fault-injection scripts, and agent configuration, is public: <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="give-the-service-a-durable-identity">Give the service a durable identity<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#give-the-service-a-durable-identity" class="hash-link" aria-label="Direct link to Give the service a durable identity" title="Direct link to Give the service a durable identity" translate="no">​</a></h2>
<p>An SLI in Azure Monitor is an extension resource on a tenant-scoped Service Group:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;/providers/Microsoft.Monitor/slis/&lt;name&gt;</span><br></div></code></pre></div></div>
<p>That scope is the point. A service is rarely one resource group: frontend here, API there, and a database in a shared subscription. A tenant-scoped Service Group gives the service an identity that outlives any particular resource layout, so the SLO follows the customer experience rather than a cluster.</p>
<p>For a platform team, this reduces the cost of change. Teams can move workloads, split deployments, or replace a dependency without making the reliability conversation start again. For a product team, it creates a stable place to discuss whether the service is meeting its promise.</p>
<p>There is an important boundary here: a <a href="https://learn.microsoft.com/azure/governance/service-groups/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Service Group</a> is an operational view, not a security boundary. It is currently in public preview, and membership does not grant access to the resources inside it. A resource can belong to multiple Service Groups, which is useful when platform, product, and customer-support teams need different views of the same service. Keep resource RBAC and Service Group RBAC as separate design decisions.</p>
<p>The Service Group does not define what the SLI measures. There is no membership involved. An SLI's scope is <code>sourceAmwAccountResourceId</code> <a href="https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">(Azure Monitor Workspace)</a> plus a metric name and dimension filters:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">"filters": [</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "namespace",   "operator": "eq",            "value": "payment" },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "service",     "operator": "eq",            "value": "frontend" },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "path",        "operator": "eq",            "value": "/checkout" },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "status_code", "operator": "notstartswith", "value": "5" }</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">]</span><br></div></code></pre></div></div>
<p>The Service Group is the SLO's home, not its definition. Membership gives you topology and rollup, but changes nothing about what any SLI computes.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="plan-the-sli-identity-path">Plan the SLI identity path<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#plan-the-sli-identity-path" class="hash-link" aria-label="Direct link to Plan the SLI identity path" title="Direct link to Plan the SLI identity path" translate="no">​</a></h3>
<p>The SLI also has an identity and data-storage path that is easy to miss when the portal hides the plumbing. Azure Monitor reads the source Azure Monitor Workspace and writes the evaluated SLI results to a destination workspace. A user-assigned managed identity needs Monitoring Reader on the source workspace, Monitoring Reader plus Monitoring Metrics Publisher on the destination workspace, and Monitoring Reader on the destination workspace's default data collection rule. The source and destination can be the same workspace, but separating raw telemetry from evaluated reliability data can make ownership and access easier to explain.</p>
<p>This is a useful handoff between platform and product teams. The product team defines what “good” means. The platform team owns the identity, workspace boundaries, collection rules, and retention that make that definition executable.</p>
<p>Membership is ordinary Bicep, with one sharp edge. <code>2026-03-01-preview</code> returned <code>RelLifecycleNotEnabledForTenant</code>; the older <code>2023-09-01-preview</code> worked:</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">targetScope</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'subscription'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">resource</span><span class="token plain"> serviceGroupMembership </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Relationships/serviceGroupMember@2023-09-01-preview'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'slisreShowcaseBicep'</span><span class="token plain"> </span><span class="token comment" style="color:rgb(98, 114, 164)">// alphanumeric only, 3-64 chars</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">properties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">targetId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">serviceGroupId</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>The relationship's parent is the member, not the Service Group, so this is subscription-scoped. The SLIs are the opposite case: their parent is the Service Group, which forces a tenant-scoped deployment and an Azure RBAC grant at <code>/</code> that Global Administrator does not confer.</p>
<p>You can express "one SLO across frontend, API and auth" today with one multi-value filter:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "dimensionName": "service",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "operator": "in",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "value": "frontend^^api^^upstream-auth-service",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p><code>in</code> and <code>notin</code> take a <code>^^</code>-delimited string, not a JSON array. That's documented in one TypeSpec comment and nowhere else.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="turn-customer-needs-into-measurable-questions">Turn customer needs into measurable questions<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#turn-customer-needs-into-measurable-questions" class="hash-link" aria-label="Direct link to Turn customer needs into measurable questions" title="Direct link to Turn customer needs into measurable questions" translate="no">​</a></h2>
<!-- -->
<p>Each SLI exists because the basic availability number leaves a different customer or business question unanswered. A sixth, <code>latency-windowed</code>, measures the same 500ms bar as mean latency per five-minute window rather than per request, and is covered separately below.</p>
<p>Azure Monitor offers two evaluation methods. Request-based evaluation asks whether the ratio of good requests to total requests meets the target. Window-based evaluation asks whether time intervals meet a quality threshold. Request-based evaluation is usually the better fit for checkout because one failed transaction should count as a failed transaction, even during a quiet period. Window-based evaluation can be useful when you want to smooth short bursts or measure the proportion of time a system remains within a limit. Choose the method with the customer experience and decision in mind, not the metric that happens to be easiest to query.</p>
<p><img decoding="async" loading="lazy" alt="The Manage SLIs grid for the six SLIs, live attainment percentages and error budget bars, then clicking into tier-availability shows the full query definition plus live Metric, Error Budget Remaining, and Burn Rate charts split by customer_tier" src="https://luke.geek.nz/assets/images/sli-portal-walkthrough-5abb53cb12a45ba8112215dda8afa108.gif" width="1494" height="782" class="img_ev3q"></p>
<table><thead><tr><th>SLI</th><th>User or business question</th></tr></thead><tbody><tr><td><code>availability</code></td><td>Is the checkout edge returning successful responses?</td></tr><tr><td><code>journey-availability</code></td><td>Does the complete request journey work across the frontend, API, and auth service?</td></tr><tr><td><code>latency</code></td><td>Can customers complete the action quickly enough to trust the service?</td></tr><tr><td><code>external-availability</code></td><td>Can a user outside the cluster reach the public service?</td></tr><tr><td><code>tier-availability</code></td><td>Is the impact concentrated in a customer segment?</td></tr></tbody></table>
<p><code>availability</code> measures the edge at <code>/checkout</code>: good requests over total. <code>journey-availability</code> spans all three tiers with the <code>in</code> filter. An edge-only SLI stays green when the API fails and the frontend serves a cached fallback, while the user's journey is broken.</p>
<p><code>latency</code> exists because a service that answers every request in nine seconds is up, and useless. Good is the <code>le="0.5"</code> histogram bucket; total is the request count.</p>
<p><code>external-availability</code> matters because every SLI above is computed from traffic generated inside the cluster. You cannot measure an outage from inside the thing that is unreachable. If DNS breaks or the load balancer misroutes, no request arrives, so there is no error metric and no denominator either. Availability reads 100% during a total outage. That is structural; it needs something outside the workload requesting the service over the real network path.</p>
<p><code>tier-availability</code> is the same measurement as <code>availability</code>, partitioned by who the customer is rather than by which component served them.</p>
<p>For any SLO, ask which signal moves if DNS breaks. If none does, it measures instrumentation rather than availability. Then ask who needs to act on the signal and what decision it should change. That turns a dashboard metric into a useful service conversation.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="choose-signals-that-represent-people-not-components">Choose signals that represent people, not components<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#choose-signals-that-represent-people-not-components" class="hash-link" aria-label="Direct link to Choose signals that represent people, not components" title="Direct link to Choose signals that represent people, not components" translate="no">​</a></h2>
<p>The demo app is instrumented with the Prometheus client because I wrote it. Most workloads you need SLOs for are not yours to change, and that's fine: the SLI reads Prometheus series from an Azure Monitor Workspace and does not care what produced them.</p>
<table><thead><tr><th>Source</th><th>App change</th><th>Gives you</th></tr></thead><tbody><tr><td>Ingress controller metrics</td><td>None</td><td><code>nginx_ingress_controller_requests{service,status}</code></td></tr><tr><td>Service mesh sidecar</td><td>None</td><td>Per-hop RED metrics</td></tr><tr><td><code>blackbox_exporter</code></td><td>None</td><td><code>probe_success</code> and certificate-expiry alerting</td></tr><tr><td>OTel auto-instrumentation</td><td>None (agent)</td><td>In-process HTTP metrics</td></tr><tr><td>App instrumentation</td><td>Yes</td><td>Whatever you choose</td></tr></tbody></table>
<p>Prefer an infrastructure-emitted signal even when you can instrument the app. It survives redeploys, language changes and vendor upgrades, and is uniform across every service behind it.</p>
<p>One trap cost me hours: for Managed Prometheus sources, <code>metricNamespace</code> must be <code>customdefault</code>, not <code>prometheus</code>. With the wrong value the metric resolves fine and SLI creation fails with errors about dimensions, which is entirely the wrong place to look.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="measuring-users-not-components">Measuring users, not components<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#measuring-users-not-components" class="hash-link" aria-label="Direct link to Measuring users, not components" title="Direct link to Measuring users, not components" translate="no">​</a></h3>
<p>An SLI's scope is a dimension filter, not a resource. <code>journey-availability</code> spans three Kubernetes Deployments purely because of:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"dimensionName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"service"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"operator"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"in"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"value"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"frontend^^api^^upstream-auth-service"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>Add a fourth tier tomorrow and it joins the SLO by emitting the label. Nothing gets registered. The SLO describes the journey; the deployment topology is free to change underneath it.</p>
<table><thead><tr><th>Filter on</th><th>The SLO says</th></tr></thead><tbody><tr><td><code>pod</code>, <code>container</code>, <code>node</code></td><td>"a component is unhealthy"</td></tr><tr><td><code>service</code>, <code>namespace</code></td><td>"a system is degraded"</td></tr><tr><td><code>path</code>, <code>journey</code></td><td>"an action users take is failing"</td></tr><tr><td><code>customer_tier</code>, <code>tenant</code>, <code>region</code></td><td>"these users are affected"</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-partition-trick">The partition trick<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#the-partition-trick" class="hash-link" aria-label="Direct link to The partition trick" title="Direct link to The partition trick" translate="no">​</a></h3>
<p>The dimensions do not have to be technical. This app emits <code>customer_tier</code> from a request header and propagates it across every hop:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">KNOWN_TIERS </span><span class="token operator">=</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">frozenset</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"premium"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"standard"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"free"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">DEFAULT_TIER </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"standard"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">tier </span><span class="token operator">=</span><span class="token plain"> request</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">headers</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"x-customer-tier"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> DEFAULT_TIER</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> tier </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">not</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">in</span><span class="token plain"> KNOWN_TIERS</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">      </span><span class="token comment" style="color:rgb(98, 114, 164)"># never let a caller mint unbounded series</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    tier </span><span class="token operator">=</span><span class="token plain"> DEFAULT_TIER</span><br></div></code></pre></div></div>
<p>The SLI partitions on it via <code>spatialAggregation.dimensions</code>:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token property">"spatialAggregation"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"Sum"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"dimensions"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"customer_tier"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>The alert uses the portal's own idiom:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") ({__name__="...:good"})</span><br></div></code></pre></div></div>
<p>Summing without a label that does not exist aggregates while preserving every real dimension, so one alert rule fires once per partition. One SLI, one rule, and the incident names the affected segment.</p>
<p>Break premium only:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">./scripts/inject-fault.sh </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--mode</span><span class="token operator">=</span><span class="token plain">errors </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--rate</span><span class="token operator">=</span><span class="token number">100</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--tier</span><span class="token operator">=</span><span class="token plain">premium</span><br></div></code></pre></div></div>
<table><thead><tr><th>At the same instant</th><th>Reports</th></tr></thead><tbody><tr><td><code>availability</code></td><td>85.02%. Service degraded. Cause unknown.</td></tr><tr><td><code>tier-availability</code></td><td>premium 0.00%, standard 100.00%, free 100.00%</td></tr></tbody></table>
<p>The second has already eliminated most of the search space: not a capacity problem, not a dependency outage, and not a deployment affecting everyone. Something is routing premium differently. That is a fault class per-service telemetry cannot see at all: every pod is healthy, every tier is up, and only the signal partitioned by who the user is moves.</p>
<p>This is where the business lens changes the response. A blended 85.02% can trigger concern, but it does not tell a product manager whether premium customers, free customers, or everyone is affected. The partitioned result supports prioritisation, customer communication, and a more honest conversation about differentiated service commitments.</p>
<p>Imagine a premium customer trying to complete checkout during this fault. The pods are healthy and the overall availability number is still above zero, but that customer cannot complete the transaction. The tier-specific SLI turns that experience into an operational fact: investigate the premium path first, communicate the impact accurately, and avoid spending the incident response effort on healthy free and standard traffic.</p>
<p>There are four limits:</p>
<ol>
<li class="">Azure cannot infer business dimensions. <code>customer_tier</code> exists because the application chose to emit it.</li>
<li class="">Cardinality is the tax. Keep business dimensions closed and small; tenant IDs are usually a bill, not a feature.</li>
<li class="">Targets are per SLI, not per partition. Differentiated targets need one SLI per tier.</li>
<li class="">In-cluster signals stay blind to the front door. Partitioning does not fix what <code>external-availability</code> exists to fix.</li>
</ol>
<p>The Service Group gives the service a tenant-scoped identity that survives resource churn. Through <code>customProperties.sliId</code>, the agent gets a service-level fact about users rather than a symptom about pods.</p>
<p>Service Groups nest too. A child group joins a parent via <code>properties.parent.resourceId</code> at create time. The differentiated-target SLIs now live in a <code>Customer Segments</code> child group under the parent, without moving the existing six SLIs or their history.</p>
<p><img decoding="async" loading="lazy" alt="A filtered Service Group tree reveals Customer Segments nested under SLI + SRE Agent showcase, then the child group&amp;#39;s overview blade showing the parent" src="https://luke.geek.nz/assets/images/nested-service-group-8ae4d49b346d746c81fe8361c825d645.gif" width="1494" height="782" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="make-the-alert-answer-a-business-question">Make the alert answer a business question<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#make-the-alert-answer-a-business-question" class="hash-link" aria-label="Direct link to Make the alert answer a business question" title="Direct link to Make the alert answer a business question" translate="no">​</a></h2>
<p>Azure Monitor has a <a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">native SLI alerting path</a>. In the portal, <code>enableAlert</code> can configure a baseline alert, fast-burn alert, and slow-burn alert, with an action group defining who is notified and what downstream action runs. That is the shortest path when you want the platform's standard SLI experience.</p>
<p>I used a second path in this project: explicit <code>Microsoft.Insights/metricAlerts</code> resources using <code>Microsoft.Azure.Monitor.PromQLCriteria</code>, scoped to the Azure Monitor Workspace. That gave me infrastructure-as-code control over the PromQL, dimensions, custom properties, and alert names used by the SRE Agent. It is not a replacement for native SLI alerting. Decide which path owns the policy, and avoid enabling both for the same condition unless duplicate notifications are intentional.</p>
<p>They read the SLI's own published metrics, <code>&lt;sli&gt;:Good</code>, <code>&lt;sli&gt;:Total</code>, and <code>&lt;sli&gt;:Value</code>:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ( sum(increase({__name__="ns::&lt;sg&gt;/m::availability:total"}[15m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  - sum(increase({__name__="ns::&lt;sg&gt;/m::availability:good"}[15m])) )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ( sum(increase({__name__="ns::&lt;sg&gt;/m::availability:total"}[15m])) * (1 - 0.99) )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) &gt; 14</span><br></div></code></pre></div></div>
<p>I originally recomputed the same signal in parallel Prometheus rules. Those selectors summed across all three tiers and left <code>/healthz</code> in the denominator, making the alert materially less sensitive than the SLO. Both objects looked correct in isolation, and total-outage testing did not catch it. Detection latency was 9m47s versus 5m20s on the same outage.</p>
<p>Alerts derived from the SLI's own output cannot drift from it. That safety property is the technical argument. The business argument is that the alert represents the same commitment people agreed to measure, rather than an engineer's approximation of it.</p>
<p><img decoding="async" loading="lazy" alt="Diagram showing how the Azure Monitor portal&amp;#39;s enableAlert setting relates to the separate metric alert and PromQL configuration" src="https://luke.geek.nz/assets/images/alerting-as-code-reveal-a3c4b80843a3823c0ea3409065160c54.svg" width="1173" height="456" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="use-error-budgets-to-guide-attention">Use error budgets to guide attention<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#use-error-budgets-to-guide-attention" class="hash-link" aria-label="Direct link to Use error budgets to guide attention" title="Direct link to Use error budgets to guide attention" translate="no">​</a></h2>
<p>Burn rate is how fast you're spending error budget. At a 99% target, a 2% error rate burns at 2x; a 20% error rate burns at 20x. That makes an error budget a decision tool: it connects reliability work to the limited amount of failure the service can afford before customers and the business feel the impact.</p>
<p>The canonical 14x over one hour and 6x over six hours assumes a 30-day budget. On a one-day window with a 0.5% budget, the long window becomes the rate limiter:</p>
<table><thead><tr><th>Injected error rate</th><th>Time for the 1h window to trip</th></tr></thead><tbody><tr><td>95% (total outage)</td><td>~4.5 min</td></tr><tr><td>20%</td><td>~22 min</td></tr><tr><td>8%</td><td>~54 min</td></tr></tbody></table>
<p>Derive the long window from the compliance window. Shortening it to 15 minutes took a 20% error rate from roughly 22 minutes to a measured 8m36s end to end.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-correction-sum_over_time-was-wrong-here">A correction: <code>sum_over_time()</code> was wrong here<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#a-correction-sum_over_time-was-wrong-here" class="hash-link" aria-label="Direct link to a-correction-sum_over_time-was-wrong-here" title="Direct link to a-correction-sum_over_time-was-wrong-here" translate="no">​</a></h3>
<p>The SLI's <code>:good</code> and <code>:total</code> are cumulative across the compliance window, not counters. <code>sum_over_time(...[15m])</code> therefore sums fifteen cumulative snapshots. It is accumulated damage, not the error rate during those fifteen minutes.</p>
<p>The practical consequence was a complete backend outage that ran for twelve minutes without crossing the 14x threshold. Treat the baseline attainment alert as the fast detector; keep burn-rate alerts for the budget-spend signal. If true short-window sensitivity is required, compute it from raw application counters with <code>rate()</code>, accepting the drift risk this design otherwise avoids.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-second-correction-rate-was-not-the-problem">A second correction: <code>rate()</code> was not the problem<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#a-second-correction-rate-was-not-the-problem" class="hash-link" aria-label="Direct link to a-second-correction-rate-was-not-the-problem" title="Direct link to a-second-correction-rate-was-not-the-problem" translate="no">​</a></h3>
<p>That conclusion was half wrong. <code>sum_over_time()</code>/<code>rate()</code> returning empty was true of exactly one test: a brand-new SLI queried within its first evaluation cycles. <code>rate()</code> and <code>increase()</code> need at least two samples spanning the lookback window. After a day of samples, <code>rate()</code>, <code>increase()</code> and <code>delta()</code> all returned normal values.</p>
<p>On identical 15-minute fault injections, <code>sum_over_time()</code> took 49 minutes to trip; <code>increase()</code> took 10m06s, most of which was the mandatory five-minute sustained-condition window. Use <code>increase()</code>, not <code>sum_over_time()</code>, and interpret an empty PromQL result as "not enough history yet" until proven otherwise.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="design-the-handoff-from-signal-to-action">Design the handoff from signal to action<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#design-the-handoff-from-signal-to-action" class="hash-link" aria-label="Direct link to Design the handoff from signal to action" title="Direct link to Design the handoff from signal to action" translate="no">​</a></h2>
<!-- -->
<p>The agent subscribes to Azure Monitor directly through <code>incidentManagementConfiguration.type: AzMonitor</code>, scoped by <code>knowledgeGraphConfiguration.managedResources</code> to the resource group. It is not wired through an action group. That is a different integration path from native SLI alert notifications: the SRE Agent receives the Azure Monitor incident, while an action group can notify people or invoke other automation.</p>
<p><img decoding="async" loading="lazy" alt="Azure Monitor SLI to Azure SRE Agent, end to end: AKS emits metrics, Managed Prometheus scrapes them into the Azure Monitor Workspace, a tenant-scoped SLI computes error budget and burn rate, an SLI-native alert raises an incident carrying customProperties, and the SRE Agent investigates with Log Analytics before proposing remediation" src="https://luke.geek.nz/assets/images/sli-to-sre-agent-e2e-a75fd83e54296ce60f3d69198e7b2e43.svg" width="1413" height="567" class="img_ev3q"></p>
<p>Each alert carries structural context:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "sliId": "/providers/.../serviceGroups/&lt;sg&gt;/providers/Microsoft.Monitor/slis/availability",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "serviceGroupId": "/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "alertKind": "fast-burn-rate",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "burnRate": "14",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "lookback": "15m",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p>The agent can resolve <code>sliId</code> and learn the target, compliance window, and filters. The incident opens as “the <code>/checkout</code> journey at the frontend edge, committed at 99% over a one-day rolling window, is at 93.4%,” not “a burn-rate rule exceeded 6”. The first version gives an operator or an agent a user-centred problem to investigate.</p>
<p>The handoff also follows a useful service-design pattern: preserve context as the problem moves between people and systems. The alert carries the service, journey, target, customer segment, and time window. The agent can then investigate the likely experience before choosing an implementation-level action.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="correlation-is-the-fastest-diagnosis">Correlation is the fastest diagnosis<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#correlation-is-the-fastest-diagnosis" class="hash-link" aria-label="Direct link to Correlation is the fastest diagnosis" title="Direct link to Correlation is the fastest diagnosis" translate="no">​</a></h3>
<table><thead><tr><th><code>availability</code></th><th><code>external-availability</code></th><th>Cause</th><th>First action</th></tr></thead><tbody><tr><td>Firing</td><td>Firing</td><td>Real workload failure</td><td>Investigate pods and events</td></tr><tr><td>Healthy</td><td>Firing</td><td>DNS, load balancer, TLS</td><td>Do not restart pods</td></tr><tr><td>Firing</td><td>Healthy</td><td>Path the probe does not exercise</td><td>Compare with <code>journey-availability</code></td></tr><tr><td>Healthy</td><td>Healthy</td><td>Suspect the telemetry</td><td>Check meta-monitoring</td></tr></tbody></table>
<p>Given a Deployment scaled to zero, the agent ran 31+ tool calls, queried Log Analytics, correctly root-caused the missing pods, and scaled the Deployment back up. Injection to recovery was about 20 minutes, roughly five of them detection.</p>
<p>The <a href="https://learn.microsoft.com/azure/sre-agent/create-agent" target="_blank" rel="noopener noreferrer" class="">current SRE Agent guidance</a> recommends starting with Reader access, where writes require approval, and moving to Privileged access only after the team trusts the workflow. That is a good pilot model: begin with investigation and proposed remediation, measure false positives and recovery quality, then grant the smallest write permission for a narrow incident class. The agent also creates or uses Application Insights and persists investigation context, so data retention, cost, region, and who can review that operational memory belong in the service design as well. The <a href="https://learn.microsoft.com/azure/sre-agent/security-overview" target="_blank" rel="noopener noreferrer" class="">SRE Agent security overview</a> describes the related identity, isolation, and telemetry considerations.</p>
<p>On a later run it refused to declare success based only on rollout status: "the rollout completed, but the probe and workspace telemetry do not yet provide a current success sample." <code>kubectl</code> confirmed the outcome it was checking for: 2/2 ready.</p>
<p><img decoding="async" loading="lazy" alt="The Azure SRE Agent&amp;#39;s incident view across the scaled-to-zero fault, the rollout it triggered completing, and independent recovery validation" src="https://luke.geek.nz/assets/images/sre-agent-incident-investigation-53626e2ecc6a75f98e027c4cfc171b9d.gif" width="1442" height="795" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="The Azure SRE Agent investigating a premium-tier fast-burn incident, identifying the breach from alert context, and requesting consent when it cannot read the SLI definition" src="https://luke.geek.nz/assets/images/live-premium-fault-investigation-c5c520edcbfcdbcba73a3a1f2e6d5d9a.gif" width="1494" height="782" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="treat-trust-as-part-of-the-service-design">Treat trust as part of the service design<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#treat-trust-as-part-of-the-service-design" class="hash-link" aria-label="Direct link to Treat trust as part of the service design" title="Direct link to Treat trust as part of the service design" translate="no">​</a></h2>
<p>The agent needs Kubernetes access to act, but permission to act is not the same as evidence that it should act. Scope the permissions tightly:</p>
<table><thead><tr><th>Role</th><th>Scope</th></tr></thead><tbody><tr><td>Azure Kubernetes Service Cluster User</td><td>Cluster; fetches a kubeconfig, grants nothing on its own</td></tr><tr><td>Azure Kubernetes Service RBAC Writer</td><td>One namespace</td></tr></tbody></table>
<p>Writer rather than Admin excludes role bindings, so the agent cannot grant itself anything further.</p>
<p><code>PreToolUse</code> hooks gate individual actions, but only if their matchers bind. Mine shipped with <code>^(restart_|scale_).*</code>, copied from another framework. The real tool names are PascalCase: <code>RunKubectlWriteCommand</code>, <code>Terminal</code>, and <code>RunInTerminal</code>. The hooks matched zero tools while reporting <code>HooksRun: 2, FinalDecision: pass</code>.</p>
<p>When the agent remediated, it used <code>RunInTerminal</code>, not the obvious kubectl-specific tool. A matcher listing only write tools would have missed it.</p>
<p>A response plan with <code>agentMode: review</code> also did not prevent execution in my test. That contradicted the current product guidance, which describes Review mode as requiring approval for write operations, so I treated it as a behaviour to retest rather than a safety guarantee.</p>
<p>From a design-thinking perspective, the question is not “can the AI fix the incident?” It is “what level of autonomy is appropriate for this incident, this customer impact, and this reversibility?” A scale-up in one namespace may be a bounded experiment. A database migration or a change affecting every tenant is a different service risk and needs a different approval model.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-re-test-showed-why-the-underlying-control-matters">The re-test showed why the underlying control matters<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#the-re-test-showed-why-the-underlying-control-matters" class="hash-link" aria-label="Direct link to The re-test showed why the underlying control matters" title="Direct link to The re-test showed why the underlying control matters" translate="no">​</a></h3>
<p>I wrote that <code>Review</code> was the one control that prevented writes after testing it once. It was not. Repeating the scaled-to-zero fault twice showed the supervised agent, ARM <code>mode: Review</code>, performing the scale operation without approval.</p>
<p>The agent's incident telemetry said <code>IncidentMitigatedByAgent: False</code> both times. Kubernetes audit logs were the only reliable witness: <code>kube-audit-admin</code> named the principal that executed the <code>PATCH .../scale</code> request. Treat that discrepancy as release-specific behaviour to verify in your tenant, not as a supported guarantee either way.</p>
<p>The cause was a hook named <code>require-approval-for-restarts</code> whose content effectively said: deny unless the action is confined to the namespace, reversible, evidence-backed, and not a delete. It did not check whether a human had approved anything. The agent was grading its own homework.</p>
<p>I rewrote the hook to unconditionally deny and re-tested with a fresh fault injection. The autonomous agent performed the fix; the supervised agent investigated and touched nothing. One clean run is evidence, not a guarantee, so keep testing the layer underneath the telemetry.</p>
<p><img decoding="async" loading="lazy" alt="The premium-tier incident as a two-act timeline: the live incident, followed by the deliberate RBAC fix and clean retry" src="https://luke.geek.nz/assets/images/premium-incident-timeline-afd5a295026b705edbbb8afb261d1353.svg" width="1372" height="446" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="test-the-experience-not-only-the-configuration">Test the experience, not only the configuration<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#test-the-experience-not-only-the-configuration" class="hash-link" aria-label="Direct link to Test the experience, not only the configuration" title="Direct link to Test the experience, not only the configuration" translate="no">​</a></h2>
<ul>
<li class="">An enabled monitoring addon does not enable collection. <code>omsAgent.enabled</code> deploys the agent; a data collection rule makes it collect. Without the DCR, Container Insights is empty and nothing reports an error. Managed Prometheus has the same DCE/DCR/DCRA trap.</li>
<li class="">Absent data makes burn-rate alerts stop firing. A zero denominator produces <code>NaN</code>, and <code>NaN &gt; threshold</code> is false. Alert explicitly on absence with <code>absent()</code>.</li>
<li class="">Incident titles reach the agent URL-encoded. <code>availability baseline alert</code> becomes <code>availability%20baseline%20alert</code>, so a plan matching spaces never matches. Name alerts without spaces.</li>
<li class="">Overlapping response plans disable the agent. Several matching plans created incidents that were marked handled within a minute while running zero tools. One matching plan produced 31 tool calls and a successful remediation.</li>
</ul>
<p>A query returning zero rows and a query that can never work are indistinguishable. Establish a positive control before believing an empty result. More broadly, test the experience you intend to protect: inject a fault, observe the customer-facing signal, check the alert context, and verify the action or refusal in the underlying audit trail.</p>
<p><img decoding="async" loading="lazy" alt="Diagram showing where the monitoring and SLI identity roles must be assigned, including the distinction between resource-group and Service Group scope" src="https://luke.geek.nz/assets/images/gotcha2-dcr-rbac-7f4c56d63188e292c3d3296b96a970c2.svg" width="1166" height="487" class="img_ev3q"></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="make-reliability-legible-to-decision-makers">Make reliability legible to decision-makers<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#make-reliability-legible-to-decision-makers" class="hash-link" aria-label="Direct link to Make reliability legible to decision-makers" title="Direct link to Make reliability legible to decision-makers" translate="no">​</a></h3>
<p>The platform engineer wanted a single pane connecting infrastructure state to user reliability. For most of this project that was two panes: pod health in Log Analytics with KQL, and SLI evaluations in the Azure Monitor Workspace with PromQL.</p>
<p>Workbooks support Prometheus as a data source. The JSON shape is undocumented, but the portal's own workbook uses this form:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "queryType": 16,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "resourceType": "microsoft.monitor/accounts",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "crossComponentResources": ["&lt;azure monitor workspace resource id&gt;"],</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "query": "{\"version\":\"PrometheusQueryProvider/1.0\",\"queryText\":\"slo:error_budget:remaining_ratio\",\"type\":\"query_range\"}",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p>The composite panel uses the minimum attainment across every SLI rather than an average. A premium-tier outage should read “we are breaching our worst commitment”, not disappear inside a blended number.</p>
<p><img decoding="async" loading="lazy" alt="The composite panel showing the minimum attainment across every SLI, then the per-SLI table identifying tier-availability-premium as the series driving it" src="https://luke.geek.nz/assets/images/composite-panel-fixed-bbba816cdc576b96aa7587dad3493bcc.gif" width="1494" height="782" class="img_ev3q"></p>
<p>That panel also closes the reporting gap for the engineering manager, who needs credible error-budget reporting but is least likely to browse nine SLI charts. Alerting on a number is not the same as being able to see its trend, and a trend without an agreed decision is not a useful outcome either.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-it-still-does-not-do">What it still does not do<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#what-it-still-does-not-do" class="hash-link" aria-label="Direct link to What it still does not do" title="Direct link to What it still does not do" translate="no">​</a></h3>
<ul>
<li class="">Service Groups are still in public preview. Do not make a production security or compliance boundary depend on Service Group membership, and confirm the current preview terms before adopting the pattern broadly.</li>
<li class="">An SLI's definition is portable, but its execution is not permission-free. The managed identity, source and destination workspaces, default data collection rule, and metric collection path all need to be healthy.</li>
<li class="">The in-cluster SLIs still measure in-cluster traffic. <code>external-availability</code> covers the public path; the rest are blind to ingress and DNS by construction.</li>
<li class="">The tier SLA table is a draft, not a signed contract. Differentiated targets exist and alert independently, but nothing enforces the SLA outside that policy document.</li>
<li class="">The one-day compliance window is a demo choice so the budget visibly moves. Never copy <code>evaluationPeriodDays: 1</code> into production without an explicit decision.</li>
<li class="">Native SLI alerts and explicit PromQL metric alerts are two policy paths. Choose one owner for each condition, or document why duplicate alert routes are useful.</li>
<li class="">SLOs do not detect outages faster than a probe. On the same outage, the external web test fired in 3m37s and the burn-rate alert in roughly five minutes. SLOs earn their place in the ambiguous middle: deciding whether a 2% error rate is worth waking someone for.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-reliability-design-checklist">A reliability design checklist<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#a-reliability-design-checklist" class="hash-link" aria-label="Direct link to A reliability design checklist" title="Direct link to A reliability design checklist" translate="no">​</a></h2>
<p>Start with the user's task and the business promise. Measure the user, not the application. Ask what moves if DNS breaks. If none of the signals move, you have instrumentation, not an SLO.</p>
<p>Choose dimensions that support a decision. Customer tier, journey, region, and tenant can explain impact, but uncontrolled cardinality can make the signal expensive and noisy.</p>
<p>Derive alerts from the SLI, never alongside it. A parallel implementation will drift silently toward being less sensitive than the SLO it enforces, and it will pass testing because total outages trip almost anything.</p>
<p>Design the handoff for the person or agent who must act. Carry the service identity, customer impact, target, and evidence into the incident instead of exposing only a rule name.</p>
<p>Verify guardrails by trying to breach them. Hooks that match nothing, plan modes that do not supervise, and rules that never evaluate all look identical to ones that work. The difference is visible only when you inject a fault and watch what happens.</p>
<p>Finally, treat reliability as a learning loop. Observe the customer experience, interpret the evidence with the people who own the outcome, test the smallest safe intervention, and update the design when reality disagrees with the model.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><p>The repository, including the Bicep, fault-injection scripts, and agent configuration, is public: <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p></div></div>]]></content:encoded>
            <category>Azure</category>
        </item>
    </channel>
</rss>