---
id: SAIFTRBL0001
moved_from:
  - guides/troubleshooting/application-permissions-provider-inconsistent-final-plan.md
title: "Terraform apply fails: Provider produced inconsistent final plan (application_permissions)"
description: A coarse depends_on leaves application_permissions IDs unknown at plan time, so Terraform plans a replacement the provider later flips to NoOp.
tags:
  - troubleshooting
  - terraform
---
# Terraform apply fails: Provider produced inconsistent final plan (application_permissions)

**One-sentence summary:** a coarse `depends_on = [module.saif-appservices]` in `auth.generated.tf` defers the `application_permissions` lookup to apply time, so deterministic app role and scope IDs become unknown at plan time and Terraform hard-errors when the provider later recomputes the same IDs.

---

## 🚨 Symptom

`terraform apply` fails with:

```text
Error: Provider produced inconsistent final plan

When expanding the plan for
module.application_permissions.module.permissions.azuread_application_app_role.app_roles["<value>"]
to include new values learned so far during apply, provider
"registry.terraform.io/hashicorp/azuread" changed the planned action from
DeleteThenCreate to NoOp.
```

The plan usually also shows:

```text
# module.application_permissions.data.azuread_application.application will be read during apply
# (depends on a resource or a module with changes pending)
```

This appears in Terraform Cloud plan and apply logs for apps whose `config.yml` defines non-empty `app_roles` or `scopes`.

---

## 📌 Applies to

| Aspect | Value |
| ------ | ----- |
| **Component** | `saif-application-permissions` Terraform module, `infra/api/auth.generated.tf` |
| **Forge versions** | 3.0.11 through 3.8.4 |
| **Related versions** | `application-permissions/saif` module `< 3.8.5`. The platform half is fixed in `iac-azure-modules` `5.1.1` |

The versions above cover the app roles and scopes half only. The same `depends_on` pattern in the platform `application` module versions independently of Forge, no Forge 3.8.x release fixes it, and it is fixed upstream in `iac-azure-modules` `5.1.1`; see the second paragraph under [Cause](#cause).

---

## 🧠 Cause

Forge 3.0.11 added a coarse `depends_on = [module.saif-appservices]` so `application_permissions` would wait for the app to exist first. That defers the module's `data.azuread_application` lookup to apply time whenever `saif-appservices` has any pending change, which leaves the application object ID, and therefore the deterministic `uuidv5`-derived app-role and scope IDs, unknown at plan time. Terraform plans a replace, then the AzureAD provider recomputes the same IDs during apply and flips the action from `DeleteThenCreate` to `NoOp`, which Terraform core rejects as an inconsistent final plan.

The same `depends_on` pattern exists a second time, inside the platform `application` module's `permissions` submodule, and affects the pre-authorization and delegated-grant resources instead of roles and scopes. Those surface as `resource already exists - to be imported into the State` rather than an inconsistent final plan, because they are planned create-before-destroy, so the provider errors on the colliding create call before Terraform core ever gets to compare plans. Same root cause, different error text. See [Terraform apply fails: resource already exists - to be imported into the State](application-permissions-resource-already-exists.md).

The same `depends_on` produces a third symptom, `Error: Cycle`, when resources in both module trees are being replaced in the same apply and Terraform cannot linearize the graph across the coarse module boundary. That is the symptom the [3.8.5 release notes](../release-notes/3.8.5.md) are written around.

All three symptoms share this root cause, but the fix below covers only half of it. It removes the `depends_on` in the generated `auth.generated.tf`, so it clears the inconsistent final plan on app roles and scopes, and it clears `Error: Cycle`, because the coarse `module.saif-appservices` edge is the one Terraform could not linearize across. Replacements can still be planned inside the platform `application` module afterwards, but they no longer cross that boundary.

The fix does not touch the second `depends_on` inside the platform `application` module, so `resource already exists` on the pre-authorization, API access, and delegated-grant resources survives it. That half is fixed separately by [iac-azure-modules#76](https://github.com/saif-corp/iac-azure-modules/pull/76), released in `iac-azure-modules` `5.1.1`. You consume it indirectly: `saif-appservices` sits several modules above where the change lands, so bumping `saif-appservices` alone does not pick it up. You get the fix when the Forge-published module you call is itself built on `iac-azure-modules >= 5.1.1`. Re-running the apply remains the only interim step for a workspace still on an older pin. Route that symptom through [Diagnose](application-permissions-resource-already-exists.md#diagnose) in the sibling article first, since `already exists` on an app role or scope is usually plain state drift rather than this `depends_on` at all.

---

## ✅ Fix

1. Update `infra/api/auth.generated.tf` to use `application-permissions` module version `>= 3.8.5, < 4.0.0`.

    ```hcl
    module "application_permissions" {
      source  = "app.terraform.io/SAIFCorp/application-permissions/saif"
      version = ">= 3.8.5, < 4.0.0"
    }
    ```

2. Run `terraform init -upgrade` so Terraform loads the updated module before validating the new `application` and `service_principal` inputs.

3. Remove `depends_on = [module.saif-appservices]` and pass the application identity directly:

    ```hcl
    application = {
      id        = module.saif-appservices.application_id
      client_id = module.saif-appservices.application_client_id
    }
    service_principal = {
      object_id = module.saif-appservices.application_principal_object_id
      client_id = module.saif-appservices.application_principal_client_id
    }
    ```

4. Re-plan. In most cases the replacement disappears once `data.azuread_application.application` resolves at plan time, and nothing needs to be destroyed. Stop here.

5. If the replacement persists after steps 1 through 3, do not destroy anything yet. Persistence alone is not evidence that the `depends_on` is still to blame. A tainted resource, an explicit `-replace`, and a genuine change to a replacement-forcing attribute all survive the module fix and all keep planning a replace. Identify the remaining trigger with the three-way check in [Diagnose](application-permissions-resource-already-exists.md#diagnose). Removing the `depends_on` only helps the deferred-read case, which the plan reports as `read_because_dependency_pending` on a nearby `data.azuread_*` source together with `replace_because_cannot_update` on the failing address.

    Only when that check confirms the deferred-read signature, the affected addresses are still in Terraform state, and the replacement still will not clear, destroy the confirmed-affected instances once, then apply again. Target exact instance keys. An address without a key selects **every** `for_each` instance of that resource, because Terraform reads a resource-level address as all instances currently associated with the resource:

    ```powershell
    terraform destroy -auto-approve '-target=module.application_permissions.module.permissions.azuread_application_app_role.app_roles["App.Read"]' '-target=module.application_permissions.module.permissions.azuread_application_permission_scope.scopes["User.Read"]'
    ```

    Substitute the `value` of each role and scope named in your own errors, and list only those. Run the same command as `terraform plan -destroy -target=...` first and read the full change list before approving, because targeting in destroy mode also pulls in the objects that depend on the target.

    !!! warning "This deletes live app roles and scopes"

        These addresses are planned destroy-then-create, so they are already the ones a failed run can leave deleted. The destroy takes the role or scope off the registration immediately, and callers that rely on it lose authorization until the follow-up apply puts it back. If that apply fails partway, they stay deleted.

        The GUIDs do not churn. `application_permissions` derives both IDs as `uuidv5("url", "https://graph.microsoft.com/${var.application_id}/${value}")`, seeded on the application ID and the role or scope value, so recreating one against the same registration with the same value recomputes the identical GUID. The seed changes only if the value changes or the app registration itself is recreated. The GUID churn described in the [3.8.5 release notes](../release-notes/3.8.5.md) came from an ID-format bug that would have changed that seed, not from recreation.

        Check what comes down with it. `azuread_application_pre_authorized.pre_authorized`, `azuread_application_api_access.pre_authorized_api_access`, and `azuread_service_principal_delegated_permission_grant.pre_authorized_consent` all declare `depends_on` on `azuread_application_permission_scope.scopes`, and `azuread_app_role_assignment.pre_authorized_app_roles` on `azuread_application_app_role.app_roles`. `azuread_app_role_assignment.role_assignments` reads the role ID out of the module's local map rather than from the role resource, so Terraform leaves it in place, but it points at a role that is missing from the registration until the apply recreates it. After the apply, confirm the registration lists every expected role and scope and that pre-authorizations and role assignments are intact.

        Do not use this to clear a `resource already exists - to be imported into the State` error. There the plan is wrong and the objects are fine, so destroying them trades working configuration for a bad plan. See [Terraform apply fails: resource already exists - to be imported into the State](application-permissions-resource-already-exists.md).

    If the objects exist in Azure but are not in state, follow [Terraform apply fails: resource already exists - to be imported into the State](application-permissions-resource-already-exists.md) instead of using `destroy -target`.

---

## 🔬 Verify

```text
Plan: 0 to add, 0 to change, 0 to destroy.
```

Two consecutive applies succeed without `app_roles` or `scopes` planning a replace, and the plan no longer says `data.azuread_application.application will be read during apply`.

---

## 📚 Related

- [Terraform apply fails: resource already exists - to be imported into the State](application-permissions-resource-already-exists.md)
- [saif-application-permissions README](https://github.com/saif-corp/forge/blob/main/src/terraform/saif-application-permissions/README.md)
- [3.8.5 release notes](../release-notes/3.8.5.md)
