Hey, Azure Guy!

Azure Policy at Scale, Part 3: One library, two engines

Written in a personal capacity. I work as a Microsoft Cloud Solution Architect, but this series is my own research and my own opinion. It is not an official Microsoft position.

Part 3 of Azure Policy at Scale: Native ALZ Governance vs EPAC follows Part 1, which argued that the tool-versus-tool comparison fails and reframed the question, and Part 2, which traced the support statements and found the boundary running through the native stack rather than around it.

As the technical centre of the series, it retires a claim that used to be true and quietly stopped being so.

Everything below reflects what I could verify on 26 August 2026, the day this was published. Repositories move quickly, so if you are reading this later, check the sources rather than trusting the dates.

The comparison that stopped making sense

Most write-ups on this subject compare EPAC’s policies with ALZ’s policies, as though there were two competing policy sets and you had to pick one.

That framing has been getting weaker for a while, and I no longer think it survives at all. Both routes can consume the same policy content, from the same repository, pinned to the same released version. Arguing about whose policy definitions are better is arguing about a library that both sides read from.

What is left after you remove the content argument is the part that actually differs: how each route manages the lifecycle of what it deployed. That turns out to be three distinct mechanisms, not two, and the claim most people still repeat about them describes only the oldest of the three.

One library

The shared component is Azure/Azure-Landing-Zones-Library. It holds the ALZ architecture and policy assets, and no deployment implementation: no pipelines, no modules, no deployment logic.

Its platform/alz folder is worth opening yourself, because the asset types tell you what the library considers to be in scope:

platform/alz/
  archetype_definitions/
  architecture_definitions/
  policy_assignments/
  policy_definitions/
  policy_set_definitions/
  role_definitions/
  alz_library_metadata.json
  alz_policy_default_values.json

Content is released under version tags, so consumers pin rather than track a branch. The current ALZ tag on 26 August 2026 is platform/alz/2026.08.0, and the same repository carries separate tag streams for Azure Monitor Baseline Alerts (platform/amba/...) and the sovereignty baseline (platform/slz/...).

Note what is not in that folder listing. There are no exemptions. I am going to leave that alone until Part 4, where it does a lot of work.

The intent behind the library is stated plainly by the team that owns it. In the January 2026 announcement of Bicep AVM for Platform Landing Zone reaching general availability:

“Our vision is to make this the single source of truth for policies across all ALZ implementations: Portal, Terraform, and Bicep.”

The same post explains what the separation buys, and the wording is precise about scope:

“Update the module to get bug fixes without touching policies”

“Refresh policies to the latest ALZ Library version without updating other components”

Policy data and deployment logic move on their own schedules. That was one of EPAC’s genuine long-standing advantages, and the native Bicep path now has a version of it.

More interesting to me is that EPAC agrees. Its own documentation on integrating with Azure Landing Zones describes the library the same way:

“The central repository that contains these policies acts as the source of truth for ALZ deployments via the portal, Bicep and Terraform.”

Two projects that are supposedly alternatives describe the same repository as the authoritative source of policy content. Whatever this decision is about, it is not about which policy set you get.

How EPAC consumes it

EPAC’s sync is two commands, and the order matters.

New-ALZPolicyDefaultStructure runs first and generates a policy structure file. EPAC’s documentation describes its contents as “a representation of a management group structure, enforcement mode settings and required default parameter values”, and states that it “must be generated at least once before executing the sync process”. You then edit it to match your environment: your management group IDs, your Log Analytics workspace, your data collection rule resource ID, your default enforcement mode.

Sync-ALZPolicyFromLibrary then generates assignment files from the library, using those values. Both commands take a -Tag parameter for pinning to a released library version, or -LibraryPath for a cloned and modified library, and both take a -Type of ALZ, AMBA or SLZ. The generated files are kept apart from your own content: EPAC says “the files will be copied into their own folder to separate them from any definitions already in the repository”.

Version 11 moved customisation into that structure file rather than requiring a forked library. You can add custom archetypes, customise a built-in archetype by adding or removing assignments, base a new archetype on an existing one, ignore an archetype entirely so no assignment files are generated for it, override a parameter for one archetype, and set enforcement mode per assignment.

That is a serious integration, and it is the strongest evidence for the convergence argument. EPAC deliberately reproduces the ALZ management group structure and archetype model rather than offering a rival one.

Why a shared library is not a shared data layer

I want to be careful here, because “both use the ALZ Library” is true and is also easy to over-read.

The two routes consume the library differently, and the two native paths do not consume it the same way as each other either.

On the Terraform path the module downloads the library into a local .alzlib folder, and the README is explicit about keeping it out of your repository: “Make sure to add .alzlib to your .gitignore file to avoid committing the downloaded ALZ library to your repository.” The documented workflow keeps the downloaded content outside the repository and feeds it to plan and apply from there.

The Bicep path does close to the opposite. Its composition layer, Azure/alz-bicep-accelerator, carries a library of its own. templates/core/governance/tooling/alz_library_metadata.json declares it as "name": "local" and describes it as a library that “allows overriding policies, archetypes, and management group architecture in the ALZ Accelerator”. It takes a versioned dependency on upstream platform/alz, pinned on 26 August 2026 to 2026.08.0, and beside it sit 217 committed files in ALZ Library format: policy definitions, policy set definitions, assignments and role definitions. The move to 2026.08.0 arrived in accelerator version 2.3.0 on 19 August 2026, in a change titled feat: update alz lib to 2026.08.0, which updated the metadata file and five of those committed library files together.

EPAC copies content out of the library into your Definitions/ folder, which is then committed to your repository and becomes the thing EPAC deploys from.

After the sync, your repository is the operational source of truth, not the library. The library is upstream.

That difference has a cost, and EPAC documents it more candidly than I expected. Its guidance on library integration warns, in the context of the sovereign landing zone content, to “treat SLZ updates as a potential breaking-change event and validate generated artifacts before deployment”, and spells out what moving between SLZ library tags can change:

“Assignment file names”

“Assignment folder/category paths under policyAssignments/SLZ/<pacSelector>

“Assignment nodeName values”

“Policy assignment names referenced in defaultParameterValues

and then the consequence:

“This is a library-content change and can affect automation that depends on specific file paths or assignment names.”

The recommended upgrade workflow that follows is five steps: pin the tag in both commands, run them into an isolated Definitions folder, compare the generated artifacts between the old and new tags, update any downstream automation that depended on the old names, and only then run Build-DeploymentPlans.

Read that as a compliment rather than a criticism. Documenting your own upgrade hazard, with a procedure attached, is better than pretending it is not there. The warning itself is scoped to SLZ content, so I will not dress it up as EPAC’s general upgrade story. My own reading is that a similar coupling follows from the sync-and-commit model wherever it is used: once generated artefacts are committed to your repository, moving to a newer library tag becomes your regeneration problem rather than a module version bump. On that reading it is not an EPAC-specific hazard, because the Bicep composition layer commits library artefacts as well. The difference is that EPAC documents the hazard. Anyone treating “we can sync from the library” as a free lunch should read that page first.

Fairness demands the other half. The native Bicep path had a much worse version of this problem, and the ALZ team said so in public:

“We’ve heard your frustrations about ‘upgrade hell’ when customizing policies in classic ALZ-Bicep.”

Their answer was to give each management group module dedicated properties for custom policies, keeping custom content separate from ALZ defaults. Conceding that the old criticism was fair is what makes the claim of improvement credible.

Two engines

Both previous parts promised a correction in this part. There are two of them, and they are connected.

The claim is that EPAC cleans up removed policy assignments and the native ALZ path does not. I repeated it myself, and as a description of classic ALZ Bicep it was accurate. That path deployed policy through ordinary incremental ARM deployments, and an incremental deployment never removes anything. Take an assignment out of your configuration and it stayed in Azure until somebody deleted it by hand.

What the claim never was is a complete description of every native implementation. The Terraform path never had that gap, because Terraform lifecycle is state-based by construction. What changed later was the Bicep side, where Deployment Stacks closed the cleanup gap too.

The Terraform path removes dropped policy resources through state. The terraform-azurerm-avm-ptn-alz module deploys policy definitions, set definitions, assignments and policy role assignments as azapi_resource resources, so its lifecycle is the ordinary Terraform one: state, plan, apply, destroy. Remove an assignment from configuration and the next apply destroys it. The module’s own summary of what it does:

“Deploy policy assets (definitions, assignments, and initiatives) according to the supplied architecture ands associated archetypes”

The Bicep path acquired an equivalent through Azure Deployment Stacks. They are an Azure Resource Manager construct, and the January 2026 announcement describes what they do:

“Deployment Stacks track the resources that should exist based on your templates and automatically clean up anything that’s no longer defined. It’s somewhat similar to Terraform’s state management, but native to Azure and built into the platform so you don’t have to manage a state file.”

Deployment Stacks would add little to the problem being compared here on the Terraform path, because that path already gets declarative lifecycle from state. Searching the current terraform-azurerm-avm-ptn-alz repository at commit 7a24786 on 26 August 2026, across all 222 files, I found no reference to deployment stacks at all. The two paths converged on declarative removal without converging on a mechanism.

So there are three lifecycle mechanisms in this comparison, not two:

Native ALZ Bicep Native ALZ Terraform EPAC
Mechanism Azure Deployment Stacks Terraform state Desired state over a deploymentRootScope
Where the record of what is managed lives In Azure, on the stack resource In the state file Derived from the repository and pacOwnerId metadata
Removing something from config Detached or deleted per actionOnUnmanage Destroyed on apply Removed according to the desired-state strategy and ownership rules
Scope of authority What the stack deployed What the state file tracks Policy resources in the deploymentRootScope, per the full or ownedOnly strategy

Those columns are not three flavours of the same feature. They fail differently, they recover differently, and they have different blast radii. Anyone still making the cleanup claim flatly should be asked which native path they mean.

Why Deployment Stacks are not an AVM capability

The second correction concerns Deployment Stacks themselves, and it explains why the first claim survived as long as it did. Stacks get filed under the wrong layer. They are described as something the native path gives you, or as an Azure Verified Modules capability. Neither holds, and the difference is not pedantry.

In the standard Bicep accelerator, the Deployment Stack is created at the orchestration layer rather than by the template being deployed. It comes from the deployment stack cmdlets called in the bootstrap pipeline, which lives in a different repository from the module and belongs to a different layer of the stack described in Part 1. Microsoft.Resources/deploymentStacks is a resource type in its own right, so this is a statement about how the standard route works rather than about every way a stack can come into existence.

The second half is simpler. AVM publishes modules in more than one language, and terraform-azurerm-avm-ptn-alz is an AVM module whose lifecycle is Terraform state. An AVM module can therefore have declarative removal with no Deployment Stack involved anywhere, which is enough on its own to settle the question.

The practical consequence matters more than the taxonomy. On the Bicep path, cleanup is a property of how you deploy rather than of what you deploy. Deploy the same template through an incremental ARM deployment and no stack tracks it, so resources you drop from the template are not removed from Azure. Run New-AzManagementGroupDeploymentStack against that same template yourself, with an actionOnUnmanage setting that deletes, and you get what the accelerator gets: the resources become managed resources of a stack, and that stack is now yours to own and to reason about. The ALZ AVM module does not change between those two runs, so it does not confer Deployment Stack lifecycle. The standard accelerator chooses that lifecycle through how the template is deployed.

Neither mechanism belongs to ALZ

The comparison so far runs between two ready-made routes, because that is the question people ask. Neither lifecycle mechanism is the property of those routes.

Azure Deployment Stacks are a Resource Manager capability and Terraform state is a Terraform capability. Both work on policy resources you wrote yourself, with no landing zone framework anywhere in the picture. AVM publishes policy assignment resource modules for Bicep at avm/res/authorization/policy-assignment, as three separately published scope variants, mg-scope, sub-scope and rg-scope, so an assignment can come from a supported module and still be deployed by a stack cmdlet you invoke yourself. On 26 August 2026 I found no equivalent standalone AVM Bicep resource module for policy definitions or policy set definitions under avm/res/authorization. At that resource-module layer, those resources would still need to be defined outside such a module.

None of which makes assembling your own policy platform a good idea for most organisations. The ALZ baseline exists so that fewer people have to, and Part 1’s whole argument was that the interesting question is about operating models rather than tools. The narrower point is that the lifecycle behaviour being weighed in this comparison is available outside both routes, which is worth knowing before treating it as the reason to pick one.

What the pipelines actually do

Documentation states intent. The accelerator pipelines are the code that runs, and Azure/accelerator-bootstrap-modules is public, so you can read them in about ten minutes.

I checked all three channels the accelerator ships on 26 August 2026:

  • Azure DevOps, alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml
  • GitHub Actions, alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml
  • Local deployment, alz/local/scripts-bicep/bicep-deploy.ps1

All three do the same thing. The deploy path builds a parameter set and calls New-AzManagementGroupDeploymentStack, New-AzSubscriptionDeploymentStack or New-AzResourceGroupDeploymentStack with:

DenySettingsMode      = "None"
ActionOnUnmanage      = "DeleteAll"
Force                 = $true

deleteAll is documented on Microsoft Learn as “use delete rather than detach for managed resources and resource groups”. So a policy assignment removed from the accelerator’s managed configuration is deleted from Azure on the next run, at management group scope, in the shipped default pipeline. That is declarative lifecycle management for policy resources, and it closes the historical cleanup gap on the Bicep path.

Two details in the same files deserve to be stated rather than glossed over, because they are the kind of thing a comparison should surface.

Deny settings are switched off. DenySettingsMode = "None" means the stack is not creating deny assignments, so there is no protection against someone editing a managed resource out of band. Desired state on the next run, yes. Locking, no.

The pipeline deletes deployment history before every deployment. Before each management group, subscription or resource group deployment, it enumerates existing deployments and removes them in batches of 200, under a comment that reads # Clean up all deployments before each deployment to avoid quota issues. Housekeeping around a platform limit rather than a defect, but I would rather mention it than pretend an enterprise-scale policy pipeline is friction-free. Scale friction of exactly this kind is part of why purpose-built policy tooling exists.

Two constraints that get conflated

Two different limits apply to Deployment Stacks at management group scope, and running them together produces a claim that would be serious if it were true: that stacks cannot manage policy resources there. The two are what a stack can manage at that scope, and what it can lock at that scope.

On the first, the documentation is clear that stacks do manage policy resources at management group scope. Learn’s scope model is explicit: “A stack at management group scope can deploy the template to the subscription.” For the management group cmdlet, the DeploymentSubscriptionId parameter “specifies the subscription used to store the managed resources. If you don’t specify the parameter, the managed resources are stored in the management group scope.” The accelerator does not specify it, which is exactly how it deploys management-group-scoped policy resources.

The genuine constraint sits on the second, and it is narrower. From the known issues page:

“Deny-assignments aren’t supported at the management group scope. However, they’re supported in a management group stack when the deployment is pointed at the subscription scope.”

The limitation is consistent with the DenySettingsMode = "None" the accelerator uses, although the pipeline sets None at every scope, so I would not present the management group constraint as the whole reason for the choice. The same page lists two further limits worth knowing before you assume stacks scale infinitely: 800 deployment stacks in a single scope, and 2,000 deny assignments at any given scope.

The plan-before-apply gap, and the month it closed

The last structural difference between the two models was the reviewable plan. EPAC separates Build-DeploymentPlans from Deploy-PolicyPlan so you can put an approval gate between deciding and doing. On the Bicep path there was no equivalent for stacks, and the ALZ team documented that themselves in January 2026:

“NOTE: Drift detection currently relies on the what-if operator with a non-deployment stack approach. We will transition to deployment stack-based drift detection once it becomes generally available.”

A named gap with a stated condition for closing it. In August 2026 the condition was met, and the dates sit close enough together to be worth setting out in order.

On 12 August 2026 Microsoft Learn published a dedicated article, Preview deployment stack changes with what-if, updated on the 14th, describing the capability and the commands that drive it. On 18 August 2026 the Azure Governance and Management Blog announced general availability:

“We are proud to announce that what-if for Azure Deployment Stacks is now generally available, in all regions.”

That post is also explicit about the scopes, which is what makes it relevant to landing zone policy rather than to resource group work:

“Stacks What-if at resource group, subscription, and management group scope, in Azure CLI, Azure PowerShell, and the Azure SDKs.”

The capability is a close functional analogue of what EPAC’s plan stage provides. Learn sets out how it differs from ordinary deployment what-if:

“For a standard deployment, what-if is an operation. You run it, Azure returns the predicted changes, and nothing is stored afterward.”

“For a deployment stack, what-if creates a what-if result resource. The result is a standalone Azure resource of type Microsoft.Resources/deploymentStacksWhatIfResults that you name yourself”

The change types include Delete and Detach, so the preview separates a resource that would be removed from Azure from one that would only leave the stack’s management, which is the part that matters when actionOnUnmanage is set to a delete option. The stack does not need to exist yet, so a first run can be previewed. And because the result is an Azure resource rather than pipeline output, it inherits Azure RBAC and the activity log. I found no equivalent documented for EPAC’s plan artefacts, which is a statement about the artefacts themselves rather than about what you can build around them in a pipeline.

Learn’s own advice for pipelines is the plan-and-gate pattern EPAC users would recognise immediately:

“In automation, capture the what-if result and gate the apply step on review or approval.”

So the platform gap is closed. The accelerator gap is not. As of 26 August 2026 the Bicep accelerator’s validation stage still runs a standard deployment what-if, and the source says so in a comment rather than leaving you to infer it:

# What-If mode - validation only using standard deployment with -WhatIf parameter

The call underneath is New-AzManagementGroupDeployment with WhatIf = $true and ValidationLevel = "providerNoRbac". A standard deployment what-if does not model stack semantics, so it will not show you what the stack is about to detach or delete, even though the deploy stage runs with ActionOnUnmanage = "DeleteAll".

I read that as timing rather than as criticism. The team named the gap themselves, published the condition for closing it, and the condition has been met for eight days at the time of writing. Nobody should expect a pipeline to have moved in that window. If you are running the Bicep accelerator today, the practical point is still worth knowing: your CI preview is not a preview of the deletions.

One more thing I noticed and cannot tidy away. The deployment stacks known issues page, dated 11 June 2026, still says “What-if isn’t yet available. The what-if operation isn’t yet supported for deployment stacks.” That page predates both the Learn article and the GA announcement, so it is stale rather than a competing account of the product’s status. Documentation lag of a week or two is unremarkable, but if you go looking for this capability and land there first, you will conclude it does not exist.

Two generations in one repository

The last piece of evidence is the one I find most persuasive, because it does not depend on anyone’s description of anything.

Azure/accelerator-bootstrap-modules still ships two generations of Bicep pipeline side by side, in both the Azure DevOps and GitHub Actions channels:

bicep-classic bicep
Deploy cmdlets New-AzTenantDeployment, New-AzManagementGroupDeployment, New-AzSubscriptionDeployment, New-AzResourceGroupDeployment New-AzManagementGroupDeploymentStack, New-AzSubscriptionDeploymentStack, New-AzResourceGroupDeploymentStack
Model Incremental ARM deployment Deployment stack
Removes resources dropped from config No Yes, ActionOnUnmanage = "DeleteAll"
Teardown A separate bicep-destroy action Handled by the stack

If your mental model of native ALZ policy management is the left column, it was accurate once. It describes a pipeline that is still in the repository and is no longer the default. A decision made against that column and never revisited is a decision made against software that has been superseded.

Microsoft has published dates for the rest of that transition. From the January 2026 post: the Bicep Classic starter module is to be removed from the Accelerator on 16 February 2026, and Azure/ALZ-Bicep is to be archived on 16 February 2027, with bug fixes, security patches and policy refreshes in between.

I am deliberately not drawing a conclusion from the classic pipeline still being present today. The timeline refers to the starter module offered by the Accelerator, and what I checked is a bootstrap pipeline directory in a different repository. Those may or may not be the same artefact, I could not establish which, and inferring a missed deadline from a folder listing would be exactly the kind of overreach this series is supposed to avoid. What I can tell you is what is in the repository on 26 August 2026, and both directories are in it.

What this settles, and what it does not

Settled, for organisations adopting the ALZ baseline: policy content is largely no longer the differentiator. Both routes can consume the same library, pinned to the same tag, with the same archetype model. If your case for EPAC is that it gives you better policies, the case has expired.

Also settled, at least in its unqualified form: “EPAC cleans up and ALZ does not.” Both native paths have declarative removal today, by unrelated mechanisms, and neither mechanism is EPAC’s. The qualification that survives is that on the Bicep path the guarantee comes from how you deploy rather than from what you deploy.

Not settled, and this is what Parts 4 and 5 are for:

Scope of authority. Deployment Stacks manage what the stack deployed. Terraform state tracks what the configuration created. EPAC’s authority is configurable, and the choice matters more than the comparison usually admits. Under full, EPAC “manages all Policy resources in the deploymentRootScope and its children” and “deletes any Policy resources not defined in the EPAC repo”. Under ownedOnly, it “manages only Policy resources defined in the EPAC repo” and “does not delete any Policy resources not defined in the EPAC repo”. The documentation calls full the default and recommends moving to it after a transition period measured in weeks. Both native paths draw their management boundary from what they deployed. EPAC starts from a deploymentRootScope you nominate, and the desired-state strategy, the pacOwnerId ownership metadata and any exclusions then determine what it may manage or remove inside that scope. Whether that is an advantage depends on whether you want a tool holding that much authority over your policy estate.

Who owns drift. A stack reconciles when the pipeline runs. Terraform reconciles when you apply. EPAC reconciles when you deploy, and under the default full strategy it deletes policy resources it does not find in the repository. The documentation is precise about the limit of that: full “deletes any Policy resources without a pacOwnerId; however, it does not delete Policy resources with a different pacOwnerId”. Those are different answers to the same question, with different failure modes at three in the morning.

Coupling cost when the library moves. The coupling differs across all three paths rather than splitting native from EPAC. The Terraform path’s documented workflow keeps its downloaded library cache outside the repository. The Bicep accelerator carries committed library-format content in its composition layer, pinned to an upstream tag. EPAC materialises synchronised library content into committed Definitions/. EPAC documents the regeneration risk explicitly for SLZ tag changes, where generated files and assignment names can change and affect downstream automation built on them. More broadly, and this part is my reading rather than something EPAC states, committing library-derived artefacts creates a coupling point wherever it happens, which is not unique to EPAC. Whichever route you take, that is a real, ongoing operational difference and it runs the opposite way to the convergence story.

One last observation, offered as an observation. EPAC’s documented technical integration is with the ALZ Library, and I found no equivalent documented integration with AVM. That fits the layer model from Part 1 precisely: EPAC replaces the composition, deployment and operations path for policy, and reads the same data layer everyone else reads.

What I could not establish

The absences, phrased as absences of documentation rather than as facts about the world:

  • I found no evidence that the ALZ Bicep accelerator has adopted stack what-if as of 26 August 2026, in any of its three deployment channels. The observation covers the eight days following general availability and is not a prediction.
  • I did not verify the specific ALZ archetype assignment filenames EPAC generates. The sync is documented, and generated names are documented as changing between SLZ library tags, which is the point that matters here.

Next

Part 4 is Hunting for the EPAC-only case, and it is the part where I try hardest to prove myself wrong. If the content argument is over and the cleanup argument is over, the honest question is what is genuinely left that only EPAC does. There is a real answer, it is shorter than most feature comparisons suggest and longer than I expected, and every item on it sits in the same layer.

For this part, the summary is that the policy content debate has been overtaken by a shared library, and what remains is a lifecycle question with three answers in it rather than two. Deployment Stacks are specific to the Bicep path and Terraform state is specific to the Terraform path, so neither is evidence for or against EPAC. If you are still weighing this decision as native versus EPAC, the first thing to fix is that there is no single native answer to weigh.

Revision log

Unchanged since publication on 26 Aug 2026. Anything I get wrong later lands here.