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. Withtrueon 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 setsdeployAppsEnabled = truefor 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:
- Silently deploying a second Purview next to your existing one.
- 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 usePOST /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 tolib/deploy/discovery-scanner. The old weaker route (POST /api/setup/scan-services) is deleted — the directory no longer exists. Details in Discovery and adoption.
/setupno longer redirects away when a hub exists. An earlier version of this page said it did; that redirect was removed andscripts/ci/check-setup-entrypoints.mjsfails the build ifredirect(reappears inapp/setup/page.tsx. The invariant it was standing in for lives where it belongs:POST /api/setup/deployrejectstopology='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 everyadoptdecision that carries nofitnessverdict:"<service>: adoption has not been validated yet — run the validation step."lib/panes/setup-wizard.tsxgates the review step's Deploy button on exactly that:nextDisabled={!!planner.plan && planBlockers(planner.plan).length > 0}.evaluateFitness()(lib/deploy/fitness.ts) andapplyFitness()(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.mdtold 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
adoptobject bag — see what the BYO wizard emits. It no longer emits per-serviceexisting*params:main.bicepstopped declaring them, and assigning one is a compile-timeBCP259.
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 withallowPartialScopes: true.
POST /api/deploy/discoveryhandles this — it establishes coverage from ARM and a container probe before the inventory query, and reports a per subscription status ofscanned·no-access·truncated. The/setupwizard's scanners now do the same (#3015 — merged, not deployed until the next roll):discover-servicesruns on the discovery-scanner module and itssubscriptionsScannedcounts subscriptions genuinely READ from the ledger — a subscription with Reader but nothing adoptable counts as scanned, one without Reader is ano-accessledger 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:
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=falsegot a second account and a hardEnterpriseTenantAlreadyExists.scripts/ci/check-adoption-catalog-sync.mjsbyte-compares eachprovision<Svc>line againstmain.bicepand 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_MAPShave no.bicepparamconsumer. Setting them does nothing at deploy time.EXISTING_ADFandEXISTING_MAPSdo 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:
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 -lThe 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
adoptbag 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 sharedlegacyAdoptFromEnvblock, so the counts differ only by incidental formatting.
dlz-attach.bicepparamis no longer "0". It declaresparam adopt(:162) and folds the same 13 services throughlegacyAdoptFromEnv(: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 thedlz-attachtopology honours each entry — that topology skips the admin plane, and theprovision<Svc>vars gate admin-plane modules. Nodlz-attachadopt 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:
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/deploycallsassertPlanIsDeployable()before ANY deploy tier fires: an adopt decision whose fitness verdict isunusableorunknownis 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 testapp/api/setup/__tests__/deploy-fitness-gate.test.tsgoes red if that caller is removed.The verdict itself now comes from
lib/deploy/fitness-probe.tsviaPOST /api/setup/validate-adoption, using your delegated ARM token (the Console identity is the fallback). One control-planeGETper adopted resource resolves C1–C3 and most of C5, two scoped authorization reads resolve C4, and Cognitive Services accounts get a secondGETfor 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_PURVIEWis folded into theadoptbag by the boundary bicepparam, andprovisionPurviewis already false because the decision isadopt.azureMapsEnabled=falseandloomFirewallEnabled=falseare 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:
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.bicepcarries those grants as module params and is skipped entirely whenexistingAdxClusterNameis 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_hubexists because the topology guard otherwise rejectstopology=tenantwhen a hub is present. Aschedule-triggered run supplies no inputs and therefore cannot set it.
regionis required and is checked against the estate (#3029). It used to be optional with aneastus2fallback, 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 aschedule(no inputs) it adopts the existing hub's region and prints that it did.
keep_resourcesnow defaults totrue(#3028). It used to default tofalse, andkeep_resources=falseinfullmode runs the teardown step on success — it enumeratesrg-csa-loom-*across the whole subscription and deletes every match, purging Key Vaults and Cognitive Services accounts. So selectingfulland changing nothing else destroyed the estate. Two things changed: the default is now the safe value, and a teardown additionally requiresconfirm_teardown_rgto 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 theDeploy input safety gatestep.
deploy_apps_enablednow defaultstrue(#3332). It used to defaultfalse, sorun_mode=fullwith nothing else set applied infrastructure and created or updated no Container App at all —app-deployments.bicepis gated on that flag, and it is the only thing that puts aLOOM_*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 itfalse: 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-attachcannot adopt any service at deploy time —dlz-attach.bicepparamreads zeroEXISTING_*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:
- 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. - 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 tokeep_resources=false(which tears down and skips the bootstrap). Read Greenfield → when a green Gov run means nothing before interpreting a Gov brownfield result. - 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.
- 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 theadoptbag 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.mdR2 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 pointmerged, not deployed #3015 wizard scanner ≠ coverage scanner #3062 — discover-servicesnow runs on the shared discovery scanner; the weakerscan-servicesroute is deletedmerged, 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.mjson gcch/gcc/il5/deploy-govmerged, 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 isOnce 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¶
- Discovery and adoption — the full service reference: what is scanned, what Loom uses each service for, and what it changes about an adopted resource
- Failure recovery — the failure taxonomy
- Bring-your-own services — the original per-service reuse reference
- Greenfield deployment