Skip to content
CSA Loom — the Microsoft Fabric experience for Azure tenants where Fabric isn't yet available: lakehouses, warehouses, notebooks, semantic models, Activator rules, Data Agents, across Commercial, GCC, GCC-High, and DoD IL5

Brownfield deployment — adopting existing Azure infrastructure

New here? Start with Prerequisites and first deploy. That page is the spine — the three human prerequisites with exact commands and verifications, the credential set per cloud, and the verify checklist. This page is the brownfield depth it links to.

Brownfield means: the target tenant already contains at least one Azure resource CSA Loom could use — an existing Purview account, a shared AI Search service, an ADLS Gen2 lake, an existing VNet — or an existing rg-csa-loom-admin-* hub you are reconciling.

Brownfield is a first-class supported path, not a variation on greenfield. It is verified independently (deploy-integrity.md R4): greenfield working proves nothing about brownfield.

This page is self-contained. You do not need to read Greenfield to follow it. Brownfield is greenfield plus a decision per service, and the phase shape is identical — it is restated here so you can run this page end to end.

The phase shape (identical to greenfield)

CSA Loom deploys in three phases, and the split is not optional: the same deployment that needs to pull images also creates the container registry empty.

Phase What it does
1 — Infrastructure az deployment sub create with deployAppsEnabled=false. Hub VNet, Private DNS, ACR, Container Apps Environment, Key Vault and every Azure backing service — but no Container Apps. This is where your adopt/create decisions take effect.
2 — Images + apps gh workflow run full-app-deploy-commercial.yml -f enable_apps_after=true. Opens the private ACR, builds every image server-side with az acr build, re-locks the registry, brings the Container Apps up — and chains phase 3.
3 — Post-deploy bootstrap The grants Bicep cannot make: MSAL app registration + admin consent, Synapse SQL admin, Purview roles, Databricks SCIM, the Spark private-endpoint fix. Sign-in does not work until this runs.

Why phase 1 must set deployAppsEnabled=false. With true on a brand-new registry, ARM tries to create Container Apps referencing <newacr>.azurecr.io/loom-console:<tag> before any image exists, and the deploy fails with a manifest/pull error. That failure is expected, not a bug — the image build is a required phase. Every parameter file sets deployAppsEnabled = true for the steady-state case, so phase 1 overrides it on the command line.

Brownfield adds one step to that shape: after phase 2, grant the Console managed identity its roles on the resources you adopted, in their subscriptions — see step 6.

Resource-group names, the teardown blast radius and CAF tags are common to both paths and live on Resource-group layout, naming and tags.

Verification status (deploy-integrity.md R4)

R4 requires greenfield and brownfield to be verified independently, per cloud. This page states where that has happened and where it has not, rather than letting the reader assume.

Cloud Status of this walkthrough
Azure Commercial Template-level only. az bicep build + az deployment sub validate were run against a byo-wizard.sh-generated .bicepparam carrying a three-service adopt bag (Purview + Synapse + Databricks) and it validated. No adopt-or-create deploy has been executed against a real populated Commercial estate, so nothing past ARM's own validation is proven.
Azure Government (GCC / GCC-High) Not verified. No Gov brownfield deploy has been run. deploy-fiab-gcch.yml's three most recent runs all ended failure (2026-08-01, -02, -03).
DoD IL5 Never executed. gh run list --workflow deploy-fiab-il5.yml returns nothing.

What "template-level only" excludes. Validation proves the template compiles and ARM accepts the parameters. It does not prove that an adopted resource is reachable, correctly permissioned, or that the Console binds to it at runtime — and it does not exercise the adopt suppression logic against a subscription that actually contains those resources. Treat a first brownfield deploy as the test of that path.

Every disagreement between this page and the shipped code is listed in Status — including four that are open defects with tracked issue numbers.


The rule this path exists to honour

Two behaviours are violations, not trade-offs:

  1. Silently deploying a second Purview next to your existing one.
  2. Failing the deploy because one already exists.

Loom's job is to discover what is there, tell you what it would use each thing for and what it would change about it, and let you decide per service.


Three decisions, per service

Decision Meaning Effect
Adopt Use the resource that already exists The provisioning module is skipped; the Console binds to your resource; the Console managed identity is granted the role it needs on it
Create Deploy a new one Standard greenfield behaviour for that service
Skip Neither The dependent Loom surfaces render an honest gate naming the missing config. Not available for every service

You mix these freely. A typical federal brownfield plan adopts the tenant Purview and a shared AI Search, creates everything else, and skips Azure Maps.


Step 1 — the multi-subscription analysis

Loom can inventory every subscription you can read and report what it could adopt. It is a read-only Azure Resource Graph query — it reads resource names, regions, SKUs and network configuration, and writes nothing.

From the Console wizard

Open /setup. The scan runs across the Analysis scope step (which subscriptions Loom may read) and the Reuse or deploy step (the per-service decision). The wizard queries Resource Graph across the scopes you consent to and returns, per service, the candidates it found, a recommendation, and the reason Loom wants that service.

Step names. Earlier revisions of this page and of Discovery and adoption said to open a step called "Scan & choose". There is no such step. The wizard rail is Cloud boundary · Deployment mode · Subscription & region · Domain name · Capacity sizing · Analysis scope · Reuse or deploy · Review & deploy.

The wizard runs on the same scanner as /api/deploy/discovery (#3015, landed in #3062 — merged 2026-08-07, and merged is not deployed). The scoped steps use POST /api/setup/estate-scan, which shares the coverage probe and returns a per-subscription ledger. GET /api/setup/discover-services (used by the read-only networking panel on the review step) delegates to lib/deploy/discovery-scanner. The old weaker route (POST /api/setup/scan-services) is deleted — the directory no longer exists. Details in Discovery and adoption.

/setup no longer redirects away when a hub exists. An earlier version of this page said it did; that redirect was removed and scripts/ci/check-setup-entrypoints.mjs fails the build if redirect( reappears in app/setup/page.tsx. The invariant it was standing in for lives where it belongs: POST /api/setup/deploy rejects topology='tenant' when a hub is already present, with a 409 that names /admin → Add landing zone as the alternative.

BLOCKING DEFECT: the wizard cannot deploy a plan containing an adopt decision

Use the CLI for brownfield today. Not as a preference — the wizard's Deploy button is disabled for any plan with an adopt decision, and it will not become enabled by anything you can do in the UI.

Measured on this branch, 2026-08-08:

  • lib/deploy/plan-model.ts → planBlockers() pushes a blocker for every adopt decision that carries no fitness verdict: "<service>: adoption has not been validated yet — run the validation step."
  • lib/panes/setup-wizard.tsx gates the review step's Deploy button on exactly that: nextDisabled={!!planner.plan && planBlockers(planner.plan).length > 0}.
  • evaluateFitness() (lib/deploy/fitness.ts) and applyFitness() (lib/deploy/plan-builder.ts) have zero production callers — grep returns only their own definitions and tests. Nothing ever attaches a verdict.

So the blocker can never clear, and the remediation text points at a "validation step" the product does not have.

It is the default, not an opt-in. recommendFor() in lib/deploy/plan-builder.ts returns adopt whenever exactly one candidate is found, and adopt-required for a tenant singleton that already exists. A tenant with one existing Purview therefore gets an adopt decision automatically, and its plan is undeployable from the UI without the operator choosing anything.

Re-measure before trusting this:

grep -n 'planBlockers' apps/fiab-console/lib/panes/setup-wizard.tsx
grep -rn 'evaluateFitness\|applyFitness' apps/fiab-console --include=*.ts --include=*.tsx | grep -v __tests__

If the second command shows a caller outside fitness.ts / plan-builder.ts, an evaluator has landed and this section is stale.

An earlier version of this documentation gave the right advice for the wrong reason. index.md told readers to drive brownfield from the CLI because of #3016 — the adopt bag not reaching the deploy. #3016 is fixed (see below). The reason the advice still holds is this fitness blocker, which is the #3014 follow-up and is not fixed. Same conclusion, different cause — recorded because acting on the wrong cause would have removed the warning.

From the CLI (works on any estate)

# Read-only inventory across every subscription the signed-in principal can see,
# with ready-to-source EXISTING_* exports for each reusable resource.
bash scripts/csa-loom/discover-services.sh
# Interactive: scan, prompt adopt / create / skip per service, and write a
# drop-in parameter file plus the matching exports.
bash scripts/csa-loom/byo-wizard.sh --boundary commercial-full

byo-wizard.sh writes platform/fiab/bicep/params/<name>.generated.bicepparam and temp/<name>.byo-exports.sh.

On an adopt pick it writes ONE entry into the adopt object bag — see what the BYO wizard emits. It no longer emits per-service existing* params: main.bicep stopped declaring them, and assigning one is a compile-time BCP259.

What it scans

Sixteen service types today. The full list, with the ARM type queried, the environment variables it emits and what Loom uses each service for, is in Discovery and adoption.

Permissions the scan needs

Need Scope Who If absent
Microsoft.Resources/subscriptions/read tenant you the subscription is not listed at all
Reader each scanned subscription you (CLI path) or the Console managed identity (wizard path) that subscription's resources are invisible — Resource Graph trims by RBAC and returns no error
Reader on each adopted resource's subscription resource Console managed identity the Console cannot read the adopted resource at runtime; grant it during the post-deploy RBAC pass

Coverage caveat. Resource Graph silently returns fewer rows when your principal lacks Reader — it does not tell you a subscription was invisible. Verified against live Commercial ARG (2022-10-01): four readable scopes plus one unreadable returns HTTP 200, four rows, facets: [], and no field naming the dropped scope, even with allowPartialScopes: true.

POST /api/deploy/discovery handles this — it establishes coverage from ARM and a container probe before the inventory query, and reports a per subscription status of scanned · no-access · truncated. The /setup wizard's scanners now do the same (#3015 — merged, not deployed until the next roll): discover-services runs on the discovery-scanner module and its subscriptionsScanned counts subscriptions genuinely READ from the ledger — a subscription with Reader but nothing adoptable counts as scanned, one without Reader is a no-access ledger row, and the response says which.


Step 2 — choose adopt or create, per service

This is the table that matters: it says, per service, what "adopt" actually does to the deploy.

Adopting suppresses creation — for every adoptable service

Set the EXISTING_* values (or hand the wizard a reuse: pick, or post a plan) and the provisioning module is skipped. Nothing else is required.

main.bicep derives one gate per service:

var provisionPurview = purviewEnabled && adoptMode(adopt, 'purview') == 'create'

so a decision of adopt suppresses the new resource on its own. The enable flag stays TRUE — it is also the Console's binding mirror, and turning it off would adopt your resource and then un-wire Loom from it.

Service Adopt via Suppression gate Notes
AI Search EXISTING_AI_SEARCH_SERVICE (+ _RG, _SUB) provisionAiSearch
API Management EXISTING_APIM (+ _RG, _SUB) provisionApim Adopting skips a ~30-minute Premium provision
ADX / Kusto EXISTING_KUSTO_CLUSTER (+ _RG, _SUB) provisionAdx See the ADX grant caveat below
AI Foundry / AOAI EXISTING_AOAI (+ _RG, _SUB, _CHAT_DEPLOYMENT, _EMBED_DEPLOYMENT) provisionFoundry, provisionAgentFoundry ONE decision now gates both the hub account and the agent project
Event Hubs EXISTING_EVENTHUB_NAMESPACE (+ _RG, _SUB) provisionEventHubs
Stream Analytics EXISTING_ASA_JOB (+ _RG, _SUB) provisionStreamAnalytics
Cosmos (Console metadata) EXISTING_COSMOS_ACCOUNT (+ _RG, _SUB) provisionConsoleCosmos
Purview EXISTING_PURVIEW (+ _RG, _SUB) provisionPurview Tenant singleton — the wizard DISABLES "create new" when one exists rather than offering it and failing EnterpriseTenantAlreadyExists. A Loom-provisioned (or re-reconciled) account creates its managed storage sealed (purviewManagedResourcesPublicNetworkAccess=Disabled) so tenant policies requiring storage publicNetworkAccess=Disabled pass RP preflight — see Failure recovery
Azure Maps EXISTING_AZURE_MAPS_ACCOUNT (+ _RG, _SUB) provisionMaps
Synapse EXISTING_SYNAPSE (+ _RG, _SUB) provisionSynapse
Databricks EXISTING_DATABRICKS (+ _RG, _SUB, _HOSTNAME) provisionDatabricks
Data Factory EXISTING_ADF (+ _RG, _SUB) provisionAdf
Azure ML EXISTING_AML_WORKSPACE (+ _RG, _SUB) provisionAml
Azure SQL (plan backing) loomPlanBackingSqlServer (+ loomSqlServerRg) (reference-only) Adopt-only by design — Loom never creates this server, it only reads

This replaced a class A / class B split. Six services used to bind the Console WITHOUT suppressing creation, so an operator who named their Purview and forgot -p purviewEnabled=false got a second account and a hard EnterpriseTenantAlreadyExists. scripts/ci/check-adoption-catalog-sync.mjs byte-compares each provision<Svc> line against main.bicep and asserts the var actually reaches the module parameter that creates the resource, so the asymmetry cannot come back unnoticed.

Class C — no adoption path exists today

There is no parameter, environment variable or wizard pick for these. They are always created new by the deploy.

Service Consequence for a brownfield estate
Hub VNet, subnets, NSGs Loom creates its own hub VNet. Choose a non-colliding hubVnetCidr (default 10.0.0.0/16). You cannot point the hub at an existing VNet
Spoke (DLZ) VNet Hardcoded 10.100.0.0/16 for every DLZ — the spokeVnetCidr parameter exists in the landing-zone module but is not threaded from the root template. If 10.100.0.0/16 is in use in your estate, this collides
Private DNS zones Loom creates and links its own privatelink.* zones. A zone that already exists in the target resource group fails PrivateDnsZoneAlreadyExists on a re-deploy — see Failure recovery
Azure Firewall instance Created (or skipped with loomFirewallEnabled=false — not firewallEnabled, which is a different, deploy-planner-scoped firewall). The policy can be reconciled by fixed name in the same resource group, but not adopted by id
Key Vault Always created new — deliberate, see below
ACR, Container Apps Environment Always created new
ADLS Gen2 lake storage Always created new. EXISTING_STORAGE is accepted by the discovery tooling but no bicep parameter reads it — the pick has no effect
Log Analytics, App Insights Always created new
PostgreSQL Flexible, Redis, Service Bus, Event Grid, Azure ML, Analysis Services Always created new (or skipped by flag where one exists)

EXISTING_STORAGE, EXISTING_POSTGRES, EXISTING_KEYVAULT, EXISTING_ADF, EXISTING_FIREWALL, EXISTING_MAPS have no .bicepparam consumer. Setting them does nothing at deploy time. EXISTING_ADF and EXISTING_MAPS do have effect through their alternate names (existingAdfFactory / loomAzureMapsAccount); the others do not. This is documented here rather than left for you to discover.

Estate-owned singletons — auto-adopted by the deploy, no input needed

Two resources are singletons by Azure rule, not by Loom design: a VNet can hold exactly one Vpn-type virtual network gateway, and a private-DNS zone can hold exactly one virtual-network link per VNet. When the estate already has one — under any name — a create-new PUT can never succeed, and the "adopt or create?" question has only one honest answer. So the deploy answers it itself:

Singleton Failure it prevents How it is adopted
VPN gateway on the hub VNet MultipleGatewaysOfTypeVpnUseSameVnet (observed live: the estate's vpngw-loom-centralus, created under an earlier naming scheme, blocked the template's vgw-loom-<region>) The workflow preflight scripts/csa-loom/preflight-brownfield-adopt.mjs discovers the existing gateway on that VNet (type-filtered — an ExpressRoute gateway is never matched) and passes existingVpnGatewayName. vpn-gateway.bicep then references it as-is and creates nothing. Adoption is reuse, not reconfiguration — the gateway's P2S/SKU config is left exactly as found
azure-api.net zone link for the hub VNet Conflict: Private zone 'azure-api.net' is already linked to the virtual network … (observed live: the hand-created link-apim-console blocked the template's link-<vnetName>) The same preflight discovers the existing link name and passes apimGatewayDnsLinkName; apim.bicep PUTs the same link, which is a no-op update

Both parameters are also first-class inputs (deploy-integrity.md R5.5): a hand-run az deployment sub create can pass -p existingVpnGatewayName=<name> apimGatewayDnsLinkName=<name> directly. Empty values keep the create-new defaults, so greenfield behaviour is byte-identical.

The discovery is three-state: an existing resource is adopted, an ARM-answered absence falls through to create-new, and a failed read fails the step — a denied az network vnet-gateway list is not evidence that no gateway exists, and deploying on that assumption is exactly how the estate got a second-gateway PUT in the first place.

Related, same pass: the APIM gateway A record (<apim>.azure-api.net) is now authored only when the service reports a private IP. A v2-tier (PremiumV2) Internal-VNet APIM can report none even in steady state; the record is then left untouched for that pass rather than failing the deploy with an InvalidTemplate index error — or worse, wiping a live record with an empty PUT.

Container image tags — adopted from the running estate, never typed

A brownfield reconcile re-PUTs every Container App that is already running, and the image reference in that PUT comes from appImageTags in the boundary's .bicepparam, each entry read with readEnvironmentVariable('LOOM_<APP>_TAG', '<default>'). That read cannot fail — the default is the point of the second argument — so a deploy with no value in scope silently writes the default over whatever the app was running, reverting any SHA-pinned roll with nothing in the log (#3161).

The deploy therefore measures the tags instead of asking for them. Every lane runs a read-only resolver before its first tag consumer, which exports one LOOM_<APP>_TAG per declared entry into the job environment:

Boundary Resolver Notes
Commercial scripts/ci/reconcile-resolve.mjs also resolves the region and deployAppsEnabled
GCC-High scripts/ci/adopt-image-tags.mjs deploy-fiab-gcch.yml
IL5 scripts/ci/adopt-image-tags.mjs deploy-fiab-il5.yml; LOOM_CONSOLE_TAG defaults to v3.0 here, read from il5.bicepparam itself
GCC — gcc.bicepparam reads no image tags, so there is nothing to adopt

Precedence, per tag: an explicitly set repo variable LOOM_<APP>_TAG wins (adoption never undoes a deliberate roll forward); otherwise the tag the app is actually running; otherwise the param file's own declared default, which is the honest answer when nothing is running that repository — deploying it creates the app, so there is no image to preserve.

Nothing is invented. An app pinned by digest, one repository served at two different tags, or a container-app query that failed are reported UNRESOLVED and left at the param default with no claim that writing it is safe. The separate gate scripts/ci/assert-no-silent-image-tag-revert.mjs then re-reads the estate itself and refuses a write sourced from the param file's own default that would move a live app off a tag nobody asked to change — the

3161 flattening. For a digest-pinned app it asks the registry the one

answerable question — does the tag this deploy would write resolve, right now, to the digest the app is running? — and proceeds only on same; a different digest is refused, and an unreadable registry stays UNKNOWN and is also refused.

Known gap, stated rather than implied. The gate tells an operator pin from a default by comparing the value against the param file's declared default, and adoption's whole purpose is to make them differ. So a tag the resolver successfully adopted is classified as a pin and can be reported no-op, move or create — never refused. If the estate moves between the resolver's read and the gate's read (a roll landing in that window), the deploy proceeds and re-asserts the older tag. Adoption cannot re-open the v0.1 flattening — an UNRESOLVED key falls back to the declared default, which the gate still refuses — but a divergence between the two reads is currently permitted. Closing it needs a per-key provenance marker distinguishing an adopted value from an operator pin, and is tracked as a follow-up.

You do not need to set any LOOM_*_TAG repo variable for a brownfield reconcile to keep what is running. Set one only when you mean to change the image.

The ADX grant caveat

The grants an ADX cluster needs — Event Hubs Data Receiver on the DLZ Event Hubs namespace, and Storage Blob Data Contributor on the DLZ lake for continuous export — are parameters of the adx-cluster.bicep module itself (ehNamespaceName / ehNamespaceRg / adlsAccountName, admin-plane main.bicep:2923-2952), not a separate module. That module is gated:

module adxCluster 'adx-cluster.bicep' = if (adxEnabled && empty(existingAdxClusterName))

An adopt decision for ADX makes existingAdxClusterName non-empty, so the module is skipped — and the grants go with it. An adopted ADX cluster receives no grants and cannot ingest until you grant them. Run the post-deploy RBAC pass (below), then verify with the Real-Time Intelligence editors before declaring it working.


Step 3 — supply the values

Three input paths. They set the same underlying parameters.

3a. Environment variables + the stock parameter file (the supported path)

Every boundary parameter file reads readEnvironmentVariable('EXISTING_*', ''), so exporting the variables before az deployment sub create is the whole mechanism.

# Adopt the tenant Purview (cross-subscription is normal for Purview) and a
# shared AI Search; create everything else.
export EXISTING_PURVIEW=my-tenant-purview
export EXISTING_PURVIEW_RG=rg-shared-governance
export EXISTING_PURVIEW_SUB=<governance-sub-id>

export EXISTING_AI_SEARCH_SERVICE=my-shared-search
export EXISTING_AI_SEARCH_RG=rg-shared-ai
export EXISTING_AI_SEARCH_SUB=<sub-id>          # only when cross-subscription

az deployment sub create \
  --location eastus2 \
  --template-file platform/fiab/bicep/main.bicep \
  --parameters platform/fiab/bicep/params/commercial-full.bicepparam \
  --parameters adminEntraGroupId="$GROUP_ID" \
  --parameters deployAppsEnabled=false \

EXISTING_* coverage per parameter file — re-measured 2026-08-06 by counting readEnvironmentVariable('EXISTING_…') occurrences and the distinct services folded into legacyAdoptFromEnv in each file on main:

Parameter file EXISTING_* reads Services foldable into adopt
commercial-full.bicepparam 57 13
commercial.bicepparam 58 13
gcc.bicepparam 58 13
gcc-high.bicepparam 57 13
il5.bicepparam 57 13
tenant-dmlz.bicepparam 57 13
dlz-attach.bicepparam 57 13

Re-measure these rather than trusting them. The counts published on 2026-08-05 were 60 / 60 / 61 / 60 / 59 / 58 / 58 — every one drifted within a day of the files being touched, which is exactly why the command is printed here instead of only the number:

cd platform/fiab/bicep/params
grep -o "readEnvironmentVariable('EXISTING_" commercial-full.bicepparam | wc -l

The 13 is the number that carries meaning; the raw read count varies with incidental formatting (some services take _RG / _SUB / extra suffixes).

This table changed shape, and an earlier version of it was wrong. Before the adopt bag each file carried a different, hand-maintained subset (the counts published then were 35 / 32 / 36 / 35 / 34 / 30 / 0). Every file now folds the same 13 services through the shared legacyAdoptFromEnv block, so the counts differ only by incidental formatting.

dlz-attach.bicepparam is no longer "0". It declares param adopt (:162) and folds the same 13 services through legacyAdoptFromEnv (:147). An earlier version of this page said the add-a-landing-zone path "cannot adopt anything at deploy time"; that is false as a statement about the parameter file. What has not been established is whether the dlz-attach topology honours each entry — that topology skips the admin plane, and the provision<Svc> vars gate admin-plane modules. No dlz-attach adopt deploy has been run. Treat adoption on that path as untested rather than as working or as unavailable, and prefer the day-2 attach wizard until it is exercised.

3b. The generated parameter file

byo-wizard.sh writes a .generated.bicepparam with the values baked in, plus an exports file for the post-deploy scripts:

bash scripts/csa-loom/byo-wizard.sh --boundary commercial-full

az deployment sub create -l eastus2 \
  -f platform/fiab/bicep/main.bicep \
  -p platform/fiab/bicep/params/commercial-full.generated.bicepparam \
  -p adminEntraGroupId="$GROUP_ID" -p deployAppsEnabled=false

source temp/commercial-full.generated.byo-exports.sh
bash scripts/csa-loom/grant-navigator-rbac.sh

Non-interactive form, for CI:

BYO_NONINTERACTIVE=1 \
  BYO_PURVIEW='reuse:my-tenant-purview:rg-shared-governance:<sub-id>' \
  BYO_APIM='reuse:my-apim:rg-apim:<sub-id>' \
  BYO_AISEARCH=new BYO_ADX=gate \
  bash scripts/csa-loom/byo-wizard.sh --boundary commercial-full --non-interactive

Each BYO_<KEY> is reuse:<name>[:<rg>[:<sub>]] | new | gate.

What the BYO wizard emits (and what it no longer emits)

On a reuse pick byo-wizard.sh writes ONE entry into the adopt object:

param adopt = union(legacyAdoptFromEnv, json(readEnvironmentVariable('LOOM_ADOPT_JSON', '{}')), {
  purview: { mode: 'adopt', target: { name: 'my-tenant-purview', rg: 'rg-shared-governance', sub: '<sub-id>' } }
})

It does not write param existingPurviewAccount = '<name>'. main.bicep stopped declaring those 36 scalars — ARM caps a template at 256 parameters and main.bicep sat at 251/256, so no further service could be made adoptable at all. Assigning one of the old names now fails to compile with

Error BCP259: The parameter "existingPurviewAccount" is assigned in the params
file without being declared in the Bicep file.

You no longer add the disable flag yourself. That was the class A / class B split, and it is gone: main.bicep derives provisionPurview = purviewEnabled && adoptMode(adopt, 'purview') == 'create', so an adopt decision suppresses the new resource on its own while the enable flag stays true (the flag is also the Console's binding mirror — turning it off would adopt your Purview and then un-wire Loom from it). Omitting a flag can no longer produce EnterpriseTenantAlreadyExists.

A pure-greenfield run emits no adopt entries; every absent key resolves to create.

3c. Live re-binding, without redeploying

An already-running estate cannot re-run main.bicep to change its Console environment. Use the env-patch script instead — it honours the same EXISTING_* names:

EXISTING_APIM=my-apim EXISTING_APIM_RG=rg-apim \
  bash scripts/csa-loom/patch-navigator-env.sh

This is also how non-deterministic values that Bicep cannot synthesize get reconciled — the Databricks workspace URL, a cross-region ADX cluster URI.


Step 4 — what is validated, per service

Day-0 adoption is validated by the platform. POST /api/setup/validate-adoption reads each resource you chose to adopt and attaches a fitness verdict to the plan before anything deploys. You do not run these checks by hand.

The one place fitness is also checked is the day-2 attach flow — /admin/landing-zones → Attach existing service — whose preflight checks reachability, network posture, and emits the exact az role assignment create when a grant is missing. That surface is registry-level (it records an attachment and wires runtime env); it is not the day-0 deploy input.

How the gate and the evaluator fit together (#3014, #3376 — merged, not deployed until the next roll). POST /api/setup/deploy calls assertPlanIsDeployable() before ANY deploy tier fires: an adopt decision whose fitness verdict is unusable or unknown is refused with 422 and the observed blocking checks, and a structurally incoherent plan (adopt of a create-only service, a second tenant singleton, a missing coordinate) is refused with 400. The guard test app/api/setup/__tests__/deploy-fitness-gate.test.ts goes red if that caller is removed.

The verdict itself now comes from lib/deploy/fitness-probe.ts via POST /api/setup/validate-adoption, using your delegated ARM token (the Console identity is the fallback). One control-plane GET per adopted resource resolves C1–C3 and most of C5, two scoped authorization reads resolve C4, and Cognitive Services accounts get a second GET for their model deployments.

The five criteria, as implemented in fitness.ts and as the gate applies them:

Check Establishes Example failure
C1 SKU / tier the SKU supports what Loom needs AI Search Free has no index quota for Loom's four indexes
C2 Region same region as the hub, or an accepted cross-region pair Purview cross-region is supported via purviewLocation; ADX cross-region adds ingest latency
C3 Network reachability the Console's Container Apps subnet can reach the resource's data plane a private-endpoint-only resource in an unpeered VNet
C4 RBAC the deploy identity holds — or can grant — the role the service needs no Microsoft.Authorization/roleAssignments/write at that scope
C5 Family-specific the service-specific precondition ADLS without hierarchical namespace; a Databricks workspace already assigned to a different Unity Catalog metastore; an AOAI account with no chat/embed deployment

What the probe reads, and what it still cannot

Everything below is read for you. The az commands are listed only so you can reproduce a verdict you disagree with — running them is not a step in the deployment.

Resolved by the probe The read behind it
ADLS hierarchical namespace (create-time-only) properties.isHnsEnabled
SKU / tier for every service, incl. AI Search and Databricks sku.name / sku.tier
Network posture — public, IP-restricted, private-endpoint properties.publicNetworkAccess, networkAcls.defaultAction, privateEndpointConnections
AOAI chat + embedding deployments the /deployments sub-resource
ADX streaming ingestion, Synapse managed VNet, Event Hubs throughput units, Stream Analytics job state, Cosmos serverless capability, APIM VNet mode, Purview tenant each service's own properties
Whether Loom holds the role, or you can grant it roleAssignments filtered to the Console principal, plus your effective permissions at that scope

Still unknown, and therefore still blocking. These need a data plane the plan-time probe holds no token for. They return the unknown verdict with the exact remediation — never a silent pass, and never unusable:

Check Why the control plane cannot answer it
aisearch.indexHeadroom index count/quota is a data-plane servicestats read
purview.rootCollectionAdmin, purview.capacityUnits Purview data-plane collection + capacity APIs
databricks.metastoreAssignment the Databricks account API, not ARM
cosmos.containerNameCollision, aml.computeQuota two-level sub-resource enumeration

If you adopt one of those services today the plan still blocks, and the verdict tells you which read failed. That gap is tracked — it is a shortfall in Loom, not an instruction to you.


Step 5 — what happens when validation fails

Today, an unusable adopted resource fails during the deploy, as an ARM error on the module that tries to use it, leaving a partially-created estate. That is the behaviour to plan around: take a snapshot of the target resource groups before a first brownfield deploy.

Look the ARM code up in Failure recovery. The brownfield-specific codes, and what each actually means:

ARM code Class What it means What to do
EnterpriseTenantAlreadyExists config A Purview account already exists in this tenant Adopt it: EXISTING_PURVIEW=<name> (+ _RG, _SUB). No enable-flag override is needed — provisionPurview is already false for an adopt decision
PrivateDnsZoneAlreadyExists config The privatelink.* zone already exists — usually a re-deploy after a partial failure Delete the conflicting zone, or deploy into a clean resource group. There is no existingPrivateDnsZones parameter — an earlier version of the runbook said there was; it does not exist
VnetAddressRangeInUse config The hub CIDR (or the hardcoded 10.100.0.0/16 spoke CIDR) collides Set hubVnetCidr to a free /16. The spoke CIDR is not currently settable — see class C
StorageAccountAlreadyTaken config Global name collision Change the deployment name prefix
RoleAssignmentExists config You are re-deploying and the grant is already there Re-run with skip_role_grants=true
AuthorizationFailed on an adopted resource's scope permission The deploy identity has no rights in the subscription holding your existing resource Grant it Contributor at that scope, or Reader + the specific role for that service
LinkedAuthorizationFailed permission The deploy is trying to write a role assignment on a resource it can read but not administer The deploy identity needs Microsoft.Authorization/roleAssignments/write at that scope
SkuNotAvailable / LocationNotAvailableForResourceType quota The service or SKU is not offered in the target region Pick another region, or adopt cross-region where supported (Purview), or disable that service

Mixing adopt and create

Nothing constrains the combination. The most common federal shape:

# Adopt: the tenant Purview (singleton), a shared AI Search, a shared APIM.
export EXISTING_PURVIEW=corp-purview
export EXISTING_PURVIEW_SUB=<governance-sub-id>
export EXISTING_AI_SEARCH_SERVICE=corp-search
export EXISTING_AI_SEARCH_RG=rg-shared-ai
export EXISTING_APIM=corp-apim
export EXISTING_APIM_RG=rg-shared-api

# Create: everything data-plane — lake, Databricks, Synapse, Event Hubs, ADX.
# Skip:   Azure Maps (region does not offer it) and the hub firewall.

az deployment sub create -l eastus2 \
  -f platform/fiab/bicep/main.bicep \
  -p platform/fiab/bicep/params/commercial-full.bicepparam \
  -p adminEntraGroupId="$GROUP_ID" \
  -p deployAppsEnabled=false \
  -p azureMapsEnabled=false \
  -p loomFirewallEnabled=false

Note there is no -p purviewEnabled=false. EXISTING_PURVIEW is folded into the adopt bag by the boundary bicepparam, and provisionPurview is already false because the decision is adopt. azureMapsEnabled=false and loomFirewallEnabled=false are still here because those are skip decisions — "deploy nothing and bind nothing" — which is a different answer from adopt.

Phase 2 and phase 3 are then exactly as described in the phase shape — one dispatch:

gh workflow run full-app-deploy-commercial.yml -f enable_apps_after=true

Leave region empty; the workflow discovers the admin plane from Resource Graph (#3029). Phase 3 (the post-deploy bootstrap) is a chained job of that workflow, so it runs automatically. If the run goes red, check which job failed before re-running everything — gh run view <run-id> --json jobs --jq '.jobs[] | select(.conclusion=="failure") | .name' — because a bootstrap-leg failure means the images and apps already deployed.

Step 6 — grant the Console identity on what you adopted

This is the one step brownfield adds that greenfield does not have. Adoption suppresses the provisioning module — and, for some services, the module that would have granted the roles. Run this after phase 2:

# Grant the Console managed identity the per-service roles on the resources you
# adopted, in THEIR subscriptions. Reads the same EXISTING_* names.
source temp/<name>.byo-exports.sh   # or export them again
bash scripts/csa-loom/grant-navigator-rbac.sh

What it covers. grant-navigator-rbac.sh grants the Console identity its per-service role on every adopted resource: AI Search, APIM, AOAI, Cosmos, Event Hubs, Synapse, Data Factory, and — since #3376 — ADX/Kusto, Databricks, Stream Analytics and Azure Maps. Nothing in that list is yours to grant by hand. ADX gets two grants, because an ARM role alone does not confer KQL management: Contributor on the cluster and an AllDatabasesAdmin Kusto principal assignment, both at the adopted cluster's own subscription and resource group.

Separate, still-open gap — the ADX cluster's own identity. The grants above are for the Console identity acting on your cluster. They do nothing for the reverse direction: an adopted ADX cluster's managed identity still needs Event Hubs Data Receiver on the DLZ namespace and Storage Blob Data Contributor on the DLZ lake to ingest, because adx-cluster.bicep carries those grants as module params and is skipped entirely when existingAdxClusterName is set. See the ADX grant caveat — that section is still accurate and this script does not close it.

If it fails:

Failure Class Remediation
AuthorizationFailed writing a role assignment permission The identity running the script needs Microsoft.Authorization/roleAssignments/write at the adopted resource's scope. Grant User Access Administrator there, or have the resource owner run the emitted az role assignment create
RoleAssignmentExists config Already granted. Safe to ignore — the script is idempotent
The script skips a service silently config The corresponding EXISTING_* variable is not exported in this shell. source the exports file again and re-run

Adopting into an existing Loom hub

If the target subscription already contains an rg-csa-loom-admin-* resource group, a second Console cannot be stamped into it. Two supported moves:

Goal How
Reconcile / retry a partially-deployed hub gh workflow run deploy-fiab-commercial.yml -f run_mode=full -f region=<the estate's region> -f allow_existing_hub=true
Add another Data Landing Zone topology=dlz-attach with target_subscription=<new-sub-id> — the DLZ lands in a new subscription

allow_existing_hub exists because the topology guard otherwise rejects topology=tenant when a hub is present. A schedule-triggered run supplies no inputs and therefore cannot set it.

region is required and is checked against the estate (#3029). It used to be optional with an eastus2 fallback, so omitting it aimed the deploy at a region the estate is not in — and the deploy then succeeded against a different, empty estate while every log line said "reconciling the existing hub". The workflow now refuses when the region you name is not the region of the hub in the subscription, and names the region to re-dispatch with. On a schedule (no inputs) it adopts the existing hub's region and prints that it did.

keep_resources now defaults to true (#3028). It used to default to false, and keep_resources=false in full mode runs the teardown step on success — it enumerates rg-csa-loom-* across the whole subscription and deletes every match, purging Key Vaults and Cognitive Services accounts. So selecting full and changing nothing else destroyed the estate. Two things changed: the default is now the safe value, and a teardown additionally requires confirm_teardown_rg to exactly equal the resolved admin resource group (rg-csa-loom-admin-<region>). A run whose inputs would tear down without that confirmation is refused before anything reaches ARM, by the Deploy input safety gate step.

deploy_apps_enabled now defaults true (#3332). It used to default false, so run_mode=full with nothing else set applied infrastructure and created or updated no Container App at all — app-deployments.bicep is gated on that flag, and it is the only thing that puts a LOOM_* env var on the Console. A green run that changed nothing you could see. It is still an input, because phase 1 of a from-scratch install must set it false: the ACR is created empty, so a Container App pull could only fail. Reconciling an existing estate does not need it — the run pins every app to the tag it is already running and refuses up front if any running tag is ambiguous.

Deliberate teardown (a disposable validation subscription) is therefore: -f run_mode=full -f region=<region> -f keep_resources=false -f confirm_teardown_rg=rg-csa-loom-admin-<region>.

dlz-attach can carry adopt decisions in its parameter file — dlz-attach.bicepparam declares param adopt (:162) and folds the same 13 services through legacyAdoptFromEnv (:147). What is not established is whether that topology honours them: it skips the admin plane, and the provision<Svc> gates guard admin-plane modules. No dlz-attach adopt deploy has been run. Until one is, treat adoption on that path as untested, and use the day-2 attach wizard (/admin/landing-zones → Attach existing service) after the DLZ exists.

An earlier revision of this page said flatly that "dlz-attach cannot adopt any service at deploy time — dlz-attach.bicepparam reads zero EXISTING_* variables." That was false (the file contains 57 such reads) and it contradicted §3a of this same page, which had already corrected it. Recorded here because a doc that disagrees with itself is worse than one that is merely out of date — a reader has no way to tell which half to trust.


What Loom will not adopt, and why

These are decisions with specific technical reasons, not gaps.

Not adoptable Why
Key Vault (the platform vault) It is the trust root for MSAL secrets, data-plane credentials and signing material. enableSoftDelete / enablePurgeProtection are one-way settings that cannot be retroactively guaranteed, and adoption would mean Loom writes platform secrets into a vault whose access policies and network ACLs a third party mutates. Referencing a customer vault as a read-only secret source for connection strings is a different, narrower capability
Container Apps Environment Its infrastructure subnet and internal ingress mode are immutable after creation. Loom requires an internal-ingress environment in a delegated subnet of a minimum size, and the environment is the unit of .internal FQDN resolution. An environment created internal=false, or in an undersized subnet, cannot be converted. Loom can be placed in an adopted VNet — that is the supported brownfield lever, and it is not built yet (class C)
Azure Firewall instance Rule-collection-group priority bands collide destructively and there is no safe merge — Loom cannot know which of your existing collections it may renumber. Adopting the policy by id (Loom adding its own uniquely-named rule-collection-group in a reserved band) is the supportable form
Anything cross-tenant Loom's identity model is single-tenant; the managed identity cannot hold a role in another tenant without a B2B/Lighthouse arrangement Loom does not model. Purview is tenant-scoped specifically
A VNet with no free contiguous address block Loom cannot expand or renumber an existing VNet — address-space changes break every existing NIC. If there is no free space for the hub subnet roles, this is a hard stop
A Databricks workspace already assigned to a foreign Unity Catalog metastore Metastore assignment is one per account per region and reassignment is destructive to existing UC objects
ADLS Gen2 without hierarchical namespace isHnsEnabled is create-time only. A StorageV2 account without it cannot serve Delta through the Gen2 API. Not a warning — pick another account or let Loom create one

Azure Government brownfield

The decision model, the EXISTING_* mechanism and the adoption table are identical — gcc.bicepparam, gcc-high.bicepparam and il5.bicepparam all read the EXISTING_* variables (57–58 reads each, the same 13 services). Four Gov-specific differences:

  1. Everything runs through GitHub Actions. There is no local-CLI Gov path; set the EXISTING_* values as repository or environment variables consumed by the deploy workflow, not as shell exports on a workstation.
  2. A green Gov run may have deployed nothing. The Gov lanes skip silently when their secrets are absent, default to run_mode=whatif-only, and default to keep_resources=false (which tears down and skips the bootstrap). Read Greenfield → when a green Gov run means nothing before interpreting a Gov brownfield result.
  3. Azure Maps is unavailable in GCC-High / IL5, so the Maps decision does not arise. Purview is not in the IL5 audit scope; IL5 uses the Atlas-on-AKS catalog instead.
  4. Unity Catalog is not available in Azure Government. Loom Unity is therefore not a parity checkbox in Gov — it is the catalog story (cloud-parity.md). Note that it has no automated roll path on either cloud; see Greenfield.

Per-cloud verification status for brownfield:

Cloud Status
Commercial Template-level only (see the table at the top of this page)
GCC Not verified. The lane's most recent runs conclude success with the deploy job skipped
GCC-High / IL4 Not verified. No Gov brownfield deploy has been run
DoD IL5 Never executed

Gov brownfield has not been verified end-to-end on any boundary. It is declared untested here rather than implied working.


Status: what is implemented, and what is not

deploy-integrity.md R8 requires the docs and the code to agree. This section is the disagreement list, measured against this branch on 2026-08-05 by reading the code, not by reading the design. Everything above that is not listed here is shipped behaviour.

An earlier version of this section was self-contradicting and is corrected here. It said the adopt-object model, the catalog collapse, the fitness suite and the failure engine were "in flight" and that "none of that is on main", while §3b of the same page documented the adopt bag as current behaviour. Four of those five have since landed; the two rows below record what actually has not.

Landed — these are shipped, not aspirational

Capability Evidence on this branch
One adopt object parameter replacing 36 existing* scalars main.bicep declares param adopt object = {}; the 36 per-service existing* scalars are gone. One ^param existing declaration remains — existingVpnGatewayName, added deliberately by #3058 for the VPN-gateway singleton (see estate-owned singletons). Measured 2026-08-08: 222 total params, cap 256
Adoption always suppresses creation one var provision<Svc> = <enableFlag> && adoptMode(adopt, '<key>') == 'create' per service — the class A / class B split is gone
Every emitter writes the bag, not the scalars byo-wizard.sh, scan-and-deploy.sh, lib/setup/adopt-bag.ts (deriveAdoptBag, consumed by every tier of app/api/setup/deploy/route.ts), lib/setup/service-choices-to-params.ts
One adoption catalog, guarded lib/deploy/adoption-catalog.ts; scripts/ci/check-adoption-catalog-sync.mjs runs in loom-guardrails.yml
/setup reachable on an estate that already has a hub the redirect is removed; scripts/ci/check-setup-entrypoints.mjs fails the build if redirect( reappears
Failure classification + bounded retry, including on Gov lib/deploy/failure-taxonomy.json + scripts/ci/deploy-retry.mjs. Measured 2026-08-08: invoked by deploy-fiab-commercial.yml, deploy-fiab-gcch.yml, deploy-fiab-gcc.yml, deploy-fiab-il5.yml and deploy-gov.yml. Guard: scripts/ci/__tests__/gov-deploy-retry-wiring.test.mjs. No Gov run has exercised it yet — wired is not proven (deploy-integrity.md R2)

Fixed — the deploy no longer discards your brownfield picks (#3016)

Landed in #3062, merged 2026-08-07. Merged is not deployed — until the next apply, the estate still behaves as it did before.

The wizard's adopt-or-create plan (and the legacy serviceChoices / existing* fields) now derive ONE adopt bag — lib/setup/adopt-bag.deriveAdoptBag() — consumed by every tier:

Deploy tier Carries your choices?
User-delegated ARM submit (the PREFERRED tier) Yes — buildDlzDeploymentParameters() emits the bag as the adopt ARM parameter
Setup Orchestrator Yes — the POST payload carries an explicit adopt field, and the orchestrator's DeployRequest declares, validates and threads it
GitHub workflow dispatch Refused, honestly — the deploy workflows declare no input that can carry the bag (the dispatch API 422s undeclared inputs, cap 10), so for a plan with adopt/skip decisions the route SKIPS this tier and falls through to the copy-paste gate rather than dispatch-and-discard. Greenfield still dispatches. Declaring a plan_json workflow input re-enables the tier for brownfield (#3016 follow-up)
Copy-paste az (the HTTP-503 fallback) Yes — same bag, both topologies

A malformed pick (a non-GUID subscription, a name that would break the CLI quoting, an adopt of a service the catalog does not know) is a 400 refusal, never a silent drop. Guard: app/api/setup/__tests__/deploy-adopt-transport.test.ts pins each tier and goes red if one stops consuming the bag.

Open — the rest, precisely

These fixes are IN THE TREE but NOT DEPLOYED (state as of 2026-08-07). Per deploy-integrity.md R2 a merged change is not a fix until it is live on the estate, so until the next apply every row below still describes what you hit today.

Was Landed in State
#3014 fitness gate inert #3062 — wires assertPlanIsDeployable() at the deploy-submit choke point merged, not deployed
#3015 wizard scanner ≠ coverage scanner #3062 — discover-services now runs on the shared discovery scanner; the weaker scan-services route is deleted merged, not deployed
#3016 adopt-bag discarded by every tier but copy-paste #3062 — one bag, threaded through every tier merged, not deployed
#3017 no classified retry on Gov #3062 — deploy-retry.mjs on gcch/gcc/il5/deploy-gov merged, not deployed; no Gov run has exercised it yet
Purview RequestDisallowedByPolicy + APIM/VPN adopt-existing idempotency #3058 — adopt-existing singletons, policy-compliant Purview managed storage, per-ARM-leaf classification merged 2026-08-07, not deployed

Re-check what is actually LIVE before relying on any of it — merged is not deployed:

curl -s https://<your-console-hostname>/build-marker.txt   # what is actually LIVE
git log --oneline <live-sha>..origin/main | wc -l          # how far behind the estate is

Once an apply lands, re-run the "How to re-measure this section" commands below rather than trusting these rows.

Gap Effect Tracked
evaluateFitness has no production producer — and this DISABLES the wizard's Deploy button on every adopt plan Not merely "un-checked adoptions pass the gate". Because planBlockers() treats a missing verdict as blocking and the review step gates Deploy on it, a brownfield plan cannot be submitted from the UI at all. Full measurement: above. Use the CLI. #3014 (follow-up)
GitHub-dispatch tier cannot carry the adopt bag Brownfield submits fall through to the copy-paste gate when only the dispatch tier is available; needs a plan_json input on the deploy workflows #3016 (follow-up)
Purview managed storage rejected by tenant policy (RequestDisallowedByPolicy); APIM private-DNS re-link Conflict; VPN gateway created under a different name than the existing one A brownfield re-apply fails on ARM leaves that adoption should have suppressed. Fixed in the tree, NOT yet deployed — PR #3058 merged 2026-08-07; it remains what you hit on the estate until the next apply #3038 (merged, not deployed)
No networking / Log Analytics / ACR / Key Vault adoption You cannot bring your own VNet, subnets, DNS zones, firewall, workspace or registry (class C above) —
EXISTING_STORAGE / _POSTGRES / _KEYVAULT / _FIREWALL have no parameter consumer Setting them does nothing at deploy time. The wizard's review step also collects a storage choice (existingLoomStorageAccount) which the deploy route declares and never reads — same class of dead input —
dlz-attach adoption is unexercised The parameter file folds all 13 services, but that topology skips the admin plane and no adopt deploy has been run on it —
No automated roll for loom-unity / iceberg-catalog / loom-trino Not brownfield-specific, but it bites a brownfield estate first because you re-apply more often. See Greenfield → what phase 2 does not update —

How to re-measure this section

Every row above is a command, not an opinion. Re-run these before trusting it, and note the expected value — a bare command with no expectation is not a check:

# Exactly ONE remains: existingVpnGatewayName (the #3058 VPN singleton).
grep -n '^param existing' platform/fiab/bicep/main.bicep

# Must stay under the ARM 256-param cap. Measured 2026-08-08: 222.
grep -c '^param ' platform/fiab/bicep/main.bicep

# Expect fitness.ts + the deploy route (the GATE is wired).
grep -rn 'assertPlanIsDeployable' apps/fiab-console --include=*.ts | grep -v __tests__

# Expect ZERO hits outside fitness.ts / plan-builder.ts — the EVALUATOR is not
# wired, which is what disables the wizard's Deploy button on any adopt plan.
grep -rn 'evaluateFitness\|applyFitness' apps/fiab-console --include=*.ts --include=*.tsx | grep -v __tests__

# Expect the one choke-point call.
grep -n 'deriveAdoptBag' apps/fiab-console/app/api/setup/deploy/route.ts

# Expect > 0 on every Gov lane (#3017, landed in #3062).
grep -c 'deploy-retry' .github/workflows/deploy-fiab-gcch.yml \
  .github/workflows/deploy-fiab-gcc.yml .github/workflows/deploy-fiab-il5.yml

An earlier revision of this block said grep -c '^param existing' … # expect 0. That expectation now fails — #3058 deliberately reintroduced one such parameter for the VPN-gateway singleton, and the same page documents it two sections earlier. A doc that supplies a check which contradicts its own body is worse than one that omits the check, so the expectation is corrected rather than deleted.


Next