Two years ago we had 47 Azure Policy assignments across our subscriptions. They were managed by hand, click-through configuration in the portal, mostly audit-mode, with one Deny assignment that nobody trusted enough to actually enforce. The platform lead was the only person who knew which initiative did what, and when she was on PTO the policy review for an audit landed on me; I spent half a day reverse-engineering the assignments before I could answer the auditor's first question.
That experience is what this post fixes. Policy-as-Code: every definition lives in a repo, every change goes through a PR, every assignment deploys via a pipeline, drift gets caught the next morning. By the end you have a working policy monorepo with unit tests for individual definitions, a deploy pipeline that tests-then-applies, a drift workflow that files an issue when something changes outside the repo, and an exemption process with explicit time-boxing.
About 600 lines across Bicep, JSON, and a small Node test runner. The result: any platform engineer can answer the auditor's first question by opening the repo, no reverse-engineering required.
Why a test runner, and not just az policy ... validate
Brief detour because building a custom test runner sounds like over-engineering until you've debugged a misbehaving policy.
The native Azure validation (az policy definition create --name ... --rules ...) checks that the JSON is parseable, not that the rules do what you intended. A policy can pass validation, deploy successfully, run in audit mode for two weeks, and then refuse to deny something it should have because the tags['environment'] field expression silently returned null where you expected a string.
The native compliance evaluation cycle takes 24 hours. By the time you find out a policy is broken, fourteen production resource groups have been created without the tag you thought you required.
The test runner closes that loop. Each definition has a .test.json file with input resources and expected outcomes; the runner evaluates the policy rule against the test resource and asserts the outcome. The whole suite runs in 2 seconds. Bugs are caught on PR, not 24 hours later in production. The runner is 150 lines of code; the alternative is a 24-hour delay between intent and verification, which is the wrong shape for any kind of safety-critical work.
What you'll have at the end
~/azure-policy-monorepo/
├── policy/
│ ├── definitions/
│ │ ├── require-tag-environment.json
│ │ ├── require-tag-environment.test.json
│ │ ├── no-public-storage.json
│ │ └── no-public-storage.test.json
│ ├── initiatives/
│ │ └── platform-baseline.json
│ ├── assignments/
│ │ ├── sub-prod.bicep
│ │ └── sub-nonprod.bicep
│ ├── exemptions/
│ │ └── 2026-q2-storage-public.bicep
│ └── test/
│ ├── run.mjs
│ └── lib.mjs
├── .github/
│ └── workflows/
│ ├── policy-pr.yml
│ ├── policy-deploy.yml
│ └── policy-drift.yml
└── README.md
Prerequisites
node --version # v22+
az --version # 2.65+
jq --version
You'll need:
- An Azure subscription with Owner permissions (policy assignments require Owner at the assignment scope)
- A GitHub repository
- Familiarity with Azure Policy's JSON definition language (mode, policyRule, parameters)
az login
SUB=$(az account show --query id -o tsv)
A note on the Owner requirement: like deployment stacks, policy assignments require Owner at the assignment scope to attach the deny-assignment-equivalent rules. If your pipeline service principal is Contributor, the deploy will fail at the assignment step. Either upgrade the SP scope-by-scope as you onboard each subscription, or use a custom role with Microsoft.Authorization/policyAssignments/* and Microsoft.Authorization/policyExemptions/*. Custom role is more work upfront, less blast-radius long-term.
Step 1: Repo scaffold
mkdir azure-policy-monorepo && cd azure-policy-monorepo
git init -b main
mkdir -p policy/{definitions,initiatives,assignments,exemptions,test}
mkdir -p .github/workflows
The folder split matters more than it looks. Definitions are what's possible. Initiatives are grouped sets you reuse. Assignments are what's enforced where. Exemptions are time-boxed escapes. Each lives in its own folder because each has a different lifecycle, a different review process, and a different blast radius.
If you put everything in a single policy/ folder you'll regret it within three months. The exemption review is a different conversation from the definition review; the assignment deploy is a different pipeline from the definition deploy. Folder boundaries reflect lifecycle boundaries; conflating them creates a maintenance fog.
Step 2: First definition + tests
policy/definitions/require-tag-environment.json:
{
"name": "require-tag-environment",
"properties": {
"displayName": "Require 'environment' tag on resource groups",
"description": "Resource groups without an 'environment' tag in [dev,test,stage,prod] are denied at create time.",
"policyType": "Custom",
"mode": "All",
"metadata": { "category": "Tags" },
"parameters": {
"allowedValues": {
"type": "Array",
"metadata": { "displayName": "Allowed environment values" },
"defaultValue": ["dev", "test", "stage", "prod"]
}
},
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Resources/subscriptions/resourceGroups" },
{
"anyOf": [
{ "field": "tags['environment']", "exists": "false" },
{
"not": {
"field": "tags['environment']",
"in": "[parameters('allowedValues')]"
}
}
]
}
]
},
"then": { "effect": "deny" }
}
}
}
policy/definitions/require-tag-environment.test.json:
{
"policyDefinition": "require-tag-environment.json",
"cases": [
{
"name": "rg with valid tag passes",
"resource": {
"type": "Microsoft.Resources/subscriptions/resourceGroups",
"name": "rg-payments-prod",
"tags": { "environment": "prod" }
},
"expect": "compliant"
},
{
"name": "rg without environment tag is denied",
"resource": {
"type": "Microsoft.Resources/subscriptions/resourceGroups",
"name": "rg-typo"
},
"expect": "noncompliant"
},
{
"name": "rg with bogus environment value is denied",
"resource": {
"type": "Microsoft.Resources/subscriptions/resourceGroups",
"name": "rg-experiment",
"tags": { "environment": "qa" }
},
"expect": "noncompliant"
},
{
"name": "non-RG resources are out of scope (compliant by default)",
"resource": {
"type": "Microsoft.Storage/storageAccounts",
"name": "stor1",
"tags": {}
},
"expect": "compliant"
}
]
}
Four test cases for what's effectively a 20-line policy. That ratio is right. The fourth case (non-RG resource) is the one that catches scope mistakes; without it, a refactor that accidentally widens the policy to all resources would pass review.
Step 3: The test runner
policy/test/lib.mjs:
// Minimal Azure Policy evaluator, handles the constructs we use.
// Real Azure has more (count, value, current, alias resolution); this evaluates
// the subset our definitions need. Add cases when you add new constructs.
export function evaluate(rule, resource, parameters = {}) {
const condition = rule.if;
const matches = evalCondition(condition, resource, parameters);
if (!matches) return { effect: "compliant", matched: false };
return { effect: rule.then.effect, matched: true };
}
function evalCondition(node, resource, parameters) {
if (node.allOf) return node.allOf.every((n) => evalCondition(n, resource, parameters));
if (node.anyOf) return node.anyOf.some((n) => evalCondition(n, resource, parameters));
if (node.not) return !evalCondition(node.not, resource, parameters);
return evalLeaf(node, resource, parameters);
}
function evalLeaf(leaf, resource, parameters) {
const value = readField(leaf.field, resource);
if ("equals" in leaf) return String(value) === String(leaf.equals);
if ("notEquals" in leaf) return String(value) !== String(leaf.notEquals);
if ("exists" in leaf) return (value !== undefined) === (leaf.exists === "true" || leaf.exists === true);
if ("in" in leaf) {
const arr = resolveValue(leaf.in, parameters);
return Array.isArray(arr) && arr.map(String).includes(String(value));
}
if ("notIn" in leaf) {
const arr = resolveValue(leaf.notIn, parameters);
return Array.isArray(arr) && !arr.map(String).includes(String(value));
}
if ("like" in leaf) {
return new RegExp("^" + String(leaf.like).replace(/\*/g, ".*") + "$").test(String(value ?? ""));
}
if ("contains" in leaf) return String(value ?? "").includes(String(leaf.contains));
throw new Error(`unhandled leaf: ${JSON.stringify(leaf)}`);
}
function readField(path, resource) {
const tagMatch = /^tags\['([^']+)'\]$/.exec(path);
if (tagMatch) return resource.tags?.[tagMatch[1]];
if (path === "type") return resource.type;
if (path === "name") return resource.name;
if (path === "location") return resource.location;
return path.split(".").reduce((o, k) => (o == null ? o : o[k]), resource);
}
function resolveValue(token, parameters) {
if (typeof token !== "string") return token;
const m = /^\[parameters\('([^']+)'\)\]$/.exec(token);
if (m) return parameters[m[1]]?.defaultValue ?? parameters[m[1]];
return token;
}
policy/test/run.mjs:
#!/usr/bin/env node
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { evaluate } from "./lib.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFS_DIR = join(__dirname, "..", "definitions");
let pass = 0, fail = 0;
const failures = [];
for (const file of readdirSync(DEFS_DIR)) {
if (!file.endsWith(".test.json")) continue;
const testFile = JSON.parse(readFileSync(join(DEFS_DIR, file), "utf8"));
const def = JSON.parse(readFileSync(join(DEFS_DIR, testFile.policyDefinition), "utf8"));
const rule = def.properties.policyRule;
const params = def.properties.parameters ?? {};
for (const c of testFile.cases) {
const result = evaluate(rule, c.resource, params);
const expected = c.expect;
const actual = result.effect === "deny" || result.effect === "audit" ? "noncompliant" : "compliant";
if (actual === expected) {
pass++;
} else {
fail++;
failures.push({ definition: testFile.policyDefinition, case: c.name, expected, actual, result });
}
}
}
console.log(`\n${pass} passed, ${fail} failed\n`);
for (const f of failures) {
console.log(` ✗ ${f.definition} :: ${f.case}`);
console.log(` expected ${f.expected}, got ${f.actual} (${JSON.stringify(f.result)})`);
}
process.exit(fail === 0 ? 0 : 1);
Make it runnable:
chmod +x policy/test/run.mjs
node policy/test/run.mjs
You should see 4 passed, 0 failed. Tests run in well under a second.
The process.exit(fail === 0 ? 0 : 1) at the bottom is the line you'll forget the first time you write a test runner and then wonder why CI keeps passing on broken tests. Without it, the script ends with exit code 0 regardless of failures.
The runner is deliberately limited; it handles the policy-language constructs we use, not all of them. When you add a new operator (count, value, alias resolution), you add support for it here. The runner growing alongside the policies is fine; the runner growing independently of the policies is a sign you're over-engineering it.
Step 4: A second definition (more complex)
policy/definitions/no-public-storage.json:
{
"name": "no-public-storage",
"properties": {
"displayName": "Storage accounts must disable public network access",
"policyType": "Custom",
"mode": "All",
"metadata": { "category": "Storage" },
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
{ "field": "properties.publicNetworkAccess", "notEquals": "Disabled" }
]
},
"then": { "effect": "deny" }
}
}
}
policy/definitions/no-public-storage.test.json:
{
"policyDefinition": "no-public-storage.json",
"cases": [
{
"name": "storage with public disabled passes",
"resource": {
"type": "Microsoft.Storage/storageAccounts",
"name": "s1",
"properties": { "publicNetworkAccess": "Disabled" }
},
"expect": "compliant"
},
{
"name": "storage with public enabled fails",
"resource": {
"type": "Microsoft.Storage/storageAccounts",
"name": "s2",
"properties": { "publicNetworkAccess": "Enabled" }
},
"expect": "noncompliant"
},
{
"name": "storage without explicit setting fails (defaults to Enabled in real Azure)",
"resource": {
"type": "Microsoft.Storage/storageAccounts",
"name": "s3",
"properties": {}
},
"expect": "noncompliant"
}
]
}
The third case is critical and easy to get wrong. Real Azure treats missing publicNetworkAccess as Enabled (the legacy default). Your test runner should match that behaviour. Without this case, a policy author can ship a definition that lets resources slip through because the field "didn't exist", and the test will pass while production fails open.
Re-run tests:
node policy/test/run.mjs
# 7 passed, 0 failed
Step 5: The initiative
policy/initiatives/platform-baseline.json:
{
"name": "platform-baseline",
"properties": {
"displayName": "Platform baseline policies",
"description": "Tag + storage rules every subscription gets",
"policyType": "Custom",
"metadata": { "category": "Platform" },
"policyDefinitions": [
{
"policyDefinitionReferenceId": "require-tag-environment",
"policyDefinitionId": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Authorization/policyDefinitions/require-tag-environment')]"
},
{
"policyDefinitionReferenceId": "no-public-storage",
"policyDefinitionId": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Authorization/policyDefinitions/no-public-storage')]"
}
]
}
}
The initiative is a grouping of definitions you assign together. Two starts feeling small; ten feels right; thirty is too many in one initiative. Split by category (Tags, Networking, Identity, Storage) when you outgrow one.
Step 6: The assignment in Bicep
policy/assignments/sub-nonprod.bicep:
targetScope = 'subscription'
param initiativeId string
resource assignment 'Microsoft.Authorization/policyAssignments@2024-04-01' = {
name: 'platform-baseline-nonprod'
properties: {
displayName: 'Platform Baseline (non-prod)'
description: 'Auditmode for non-prod, ramp to deny when ready'
policyDefinitionId: initiativeId
enforcementMode: 'DoNotEnforce' // audit mode
}
}
output assignmentId string = assignment.id
policy/assignments/sub-prod.bicep:
targetScope = 'subscription'
param initiativeId string
resource assignment 'Microsoft.Authorization/policyAssignments@2024-04-01' = {
name: 'platform-baseline-prod'
properties: {
displayName: 'Platform Baseline (prod)'
description: 'Enforced in prod'
policyDefinitionId: initiativeId
enforcementMode: 'Default' // deny is enforced
}
identity: { type: 'None' }
}
output assignmentId string = assignment.id
The DoNotEnforce vs Default is the difference between audit mode and enforcement mode, and it's the lever that makes a graduated rollout possible. New rules go to non-prod in audit, run for a sprint or two, get reviewed, then either get promoted to prod with Default or get fixed if the audit found false positives. Audit mode in non-prod, enforcement mode in prod, is the rollout pattern that actually works. Skipping the audit phase is how you ship a deny rule that takes down a workload nobody knew was non-compliant.
Step 7: Deploy script
scripts/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
SUB="${SUB:?missing SUB}"
echo "=== Deploying definitions ==="
for f in policy/definitions/*.json; do
[[ "$f" == *.test.json ]] && continue
NAME=$(jq -r '.name' "$f")
az policy definition create \
--name "$NAME" \
--rules "$(jq -c '.properties.policyRule' "$f")" \
--params "$(jq -c '.properties.parameters // {}' "$f")" \
--display-name "$(jq -r '.properties.displayName' "$f")" \
--description "$(jq -r '.properties.description // .properties.displayName' "$f")" \
--mode "$(jq -r '.properties.mode' "$f")" \
--subscription "$SUB" \
> /dev/null
echo " + $NAME"
done
echo "=== Deploying initiative ==="
INIT="policy/initiatives/platform-baseline.json"
az policy set-definition create \
--name "$(jq -r '.name' "$INIT")" \
--definitions "$(jq -c '.properties.policyDefinitions' "$INIT")" \
--display-name "$(jq -r '.properties.displayName' "$INIT")" \
--subscription "$SUB" \
> /dev/null
echo "=== Deploying assignments ==="
INITIATIVE_ID="/subscriptions/$SUB/providers/Microsoft.Authorization/policySetDefinitions/platform-baseline"
az deployment sub create \
--location eastus \
--template-file policy/assignments/sub-prod.bicep \
--parameters initiativeId="$INITIATIVE_ID" \
--query 'properties.outputs'
Step 8: The PR pipeline
.github/workflows/policy-pr.yml:
name: policy-pr
on:
pull_request:
paths: ["policy/**"]
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate JSON shape
run: |
for f in policy/definitions/*.json policy/initiatives/*.json; do
[[ "$f" == *.test.json ]] && continue
jq -e 'type=="object" and has("properties")' "$f" >/dev/null \
|| { echo "::error::malformed $f"; exit 1; }
done
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: node policy/test/run.mjs
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: What-if assignment changes
run: |
az deployment sub what-if \
--location eastus \
--template-file policy/assignments/sub-nonprod.bicep \
--parameters initiativeId="/subscriptions/$(az account show --query id -o tsv)/providers/Microsoft.Authorization/policySetDefinitions/platform-baseline"
Three gates: JSON validity, unit tests, Bicep what-if. The PR can't merge until all three pass. Reviewers see the what-if output inline.
The paths: ["policy/**"] filter means non-policy PRs don't trigger this workflow. Important for repo hygiene; otherwise the policy CI runs on every doc change.
Step 9: The drift workflow
.github/workflows/policy-drift.yml:
name: policy-drift
on:
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
permissions:
id-token: write
contents: read
issues: write
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Capture live state
run: |
az policy assignment list \
--query "[].{name:name, scope:scope, definitionId:policyDefinitionId, enforcement:enforcementMode}" \
-o json | jq 'sort_by(.name)' > live-assignments.json
az policy definition list --query "[?policyType=='Custom'].{name:name, displayName:displayName}" \
-o json | jq 'sort_by(.name)' > live-definitions.json
- name: Compare to repo
id: compare
run: |
jq -s '[.[] | {name: .name, displayName: .properties.displayName}]' \
policy/definitions/[!.]*[!t].json | jq 'sort_by(.name)' > expected-definitions.json
if ! diff -q expected-definitions.json live-definitions.json > /dev/null; then
echo "drift_definitions=true" >> $GITHUB_OUTPUT
fi
jq '[.[].name]' live-assignments.json | sort > /tmp/live.txt
ls policy/assignments/*.bicep | xargs -n1 basename | sed 's/\.bicep$//' \
| sort > /tmp/expected.txt
if ! diff -q /tmp/live.txt /tmp/expected.txt > /dev/null; then
echo "drift_assignments=true" >> $GITHUB_OUTPUT
fi
- name: File issue on drift
if: steps.compare.outputs.drift_definitions == 'true' || steps.compare.outputs.drift_assignments == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Policy drift detected\n\n';
try {
body += '### Live definitions\njson\n' + fs.readFileSync('live-definitions.json', 'utf8') + '\n\n'; body += '### Live assignments\njson\n' + fs.readFileSync('live-assignments.json', 'utf8') + '\n```\n';
} catch (e) {}
await github.rest.issues.create({
...context.repo,
title: Policy drift detected ${new Date().toISOString().slice(0,10)},
body,
labels: ['policy', 'drift'],
});
Drift on a policy assignment is one of those things that's invisible until it isn't. Someone disables an assignment in the portal during an investigation and forgets to re-enable. Eight weeks later, an audit notices the assignment is `Disabled` and asks why. The drift workflow catches this on the next sunrise and files an issue with the live state.
## Step 10: The exemption process
`policy/exemptions/2026-q2-storage-public.bicep`:
bicep
targetScope = 'resourceGroup'
param assignmentId string param ticket string = 'PLAT-1234' param expiry string = '2026-06-30T23:59:59Z'
resource exemption 'Microsoft.Authorization/policyExemptions@2022-07-01-preview' = { name: 'storage-public-${ticket}' properties: { policyAssignmentId: assignmentId exemptionCategory: 'Waiver' expiresOn: expiry description: 'Tracking ticket: ${ticket}. Expires ${expiry}.' metadata: { ticket: ticket reviewer: 'platform-team@example.com' } } }
Three rules every exemption follows in this repo:
1. **Filename includes the quarter.** `2026-q2-...` makes stale exemptions visible at a glance.
2. **Ticket reference is required.** No anonymous exemptions; if you can't link to a ticket, you don't get the exemption.
3. **`expiresOn` is set.** Azure auto-revokes; no permanent exemptions in version control.
The third rule is the cultural fix. Permanent exemptions accumulate. A team adds an exemption for "the migration is in progress, will fix next sprint", the migration finishes, the exemption stays, six quarters later you have 40 exemptions and nobody remembers which are still needed. Forcing every exemption to have an expiry forces the conversation about whether it should still exist.
A monthly job (similar to drift) lists policy exemptions where `expiresOn` is within 14 days and files an issue. Forces the conversation about whether the exemption should be renewed or the underlying problem fixed.
## Production checklist
1. **Two assignments to start.** One Deny initiative for rules you'll enforce day one, one Audit initiative for the rest. Two scopes (sub-prod, sub-nonprod) × two initiatives = four assignments. Easy to reason about.
2. **The test runner is for *your* policy constructs only.** When you add a new operator, add a case to `evalLeaf`. It's a 150-line file by design; keep it small.
3. **Audit mode has a deadline.** Every audit-mode assignment gets a 1- or 2-sprint window. After that it gets enforced or deleted. No "audit-only forever".
4. **Pin the Bicep `apiVersion`.** Policy assignment shape changes occasionally; pinning prevents surprise diffs on next deploy.
5. **Wire the drift issue to PagerDuty.** Drift on production policy isn't an "interesting bug", it's an "investigate this morning" alert.
## Troubleshooting
`PolicyDefinitionInvalidEffect`, Test passes but Azure rejects the deploy. The `effect` value isn't in the policy mode's allowed list. Common: `mode: "Indexed"` doesn't allow `auditIfNotExists`; switch to `mode: "All"`.
`PolicyDefinitionNotFound` from initiative, The initiative references a policy by ID, but the ID is built relative to the subscription. If you deploy the initiative to a management group, the relative ID won't resolve. Either deploy at sub scope or use a fully-qualified ID.
`enforcementMode: "DoNotEnforce"` audited but didn't deny, Working as intended. Audit mode reports compliance, doesn't block. To enforce, change to `enforcementMode: "Default"`.
Test passes but production behavior differs, Your harness (`lib.mjs`) is missing a construct Azure supports. Inspect the policy rule for an unhandled operator (`count`, `value`, alias resolution) and add support for it.
`Microsoft.Authorization is not registered`, On a fresh subscription, register the provider:
az provider register --namespace Microsoft.Authorization --wait
Test runner exits 0 even with failures, `process.exit(fail === 0 ? 0 : 1)` is at the bottom of `run.mjs`; confirm it's there. Without it, CI silently passes.
## What this changes about how the team manages policy
The obvious win is the inventory hygiene. Every policy lives in a repo, every change goes through a PR, every assignment is reproducible from code. The before-state, where the platform lead was the only person who knew which initiative did what, is gone.
The less-obvious win is what happens to **how the team thinks about policy changes**. Before this build, "should we add a new policy" was a conversation that started "let me click through the portal and see what we have". After, the conversation starts "open `policy/definitions/`, here are the eleven rules we enforce". The barrier to participation collapses; junior engineers can propose policy changes in PRs because they can see what exists and what shape changes take. Policy stops being something only the platform lead understands.
The audit conversation also changes. Before: an auditor asks "show me the policies enforced in subscription X" and you spend an hour navigating the portal and assembling a snapshot. After: you open the repo, point at `policy/assignments/sub-prod.bicep`, the auditor reads ten lines of Bicep, asks two follow-up questions, and you're done in fifteen minutes. The repo *is* the audit document. The policy becomes legible.
For an organisation that's been managing policy by clickops for years, the migration is uncomfortable for the first month and then quickly becomes the only way to do it. The 600 lines of code are the means; the cultural change (policy as a normal repo with normal review workflow) is the deliverable. Once you've done it, you don't go back to clicking around in the portal, in the same way you don't go back to deploying infrastructure by clicking around in the portal.
Six months in, the orphaned-policy reports stopped landing in our inbox. The audit conversation that used to take half a day takes thirty minutes. Three of the four newest hires on the platform team had landed policy PRs in their first sprint. None of these were goals when we started; they're the second-order effects of making policy something the whole team can see and edit, instead of something locked behind one person's portal session.

Conversation
Reactions & commentsLiked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.