Govern a multi-agent topology lifecycle
Customer scenario
Fabrikam must govern releases of a code-review topology containing a scanner, reviewer, and orchestrator. The team must know which Foundry versions belong to each logical release, prove why the topology was approved, control tenant usage, and retire obsolete component versions without breaking consumers.
Lab scenario
You will provision a Foundry project, model deployment, and Cosmos DB usage registry. You will inventory Foundry agents through AIProjectClient, implement a release gate, create versioned prompt agents for the scanner, reviewer, and orchestrator, enforce concrete usage controls, and execute a governed version retirement.
By the end of this exercise, you will be able to:
- Enumerate prompt agents and immutable versions from the Foundry registry.
- Promote one approved three-agent topology as a governed release.
- Preserve a reproducible mapping between logical topology versions and Foundry versions.
- Enforce a 60-request-per-minute limit and 1,000,000-token monthly quota atomically.
- Meter usage and allocate estimated USD charges to a tenant and cost center.
- Retire one disposable prompt-agent version through explicit governance controls.
Important: Use only the synthetic prompt-agent versions created during this exercise. Never point the retirement command at any other agent or version.
Task 1: Prepare the lab
-
Install Python 3.10+, Azure CLI 2.80+, Azure Developer CLI 1.23+, and the
azure.ai.agentsazd extension. -
Use a subscription with Foundry availability, permission to create an account and project, permission to deploy the selected model, and project access that allows prompt-agent version management.
-
If you haven't already done so, clone the lab source repository, or fork the repository and clone your fork:
git clone https://github.com/MicrosoftLearning/mslearn-ai-multi-agents.git
- Open the cloned repository in Visual Studio Code.
- Validate the required tools, credentials, and active subscription from the VS Code terminal:
cd Allfiles\12-fabrikam-agent-lifecycle-governance
az version
azd version
python --version
az account show --output table
Architecture checkpoint
Review infra/main.bicep, the approved version manifest, src/lifecycle.py, and src/usage.py, then use this table to locate each operation that can create, meter, or delete state:
| Function | Control to inspect |
|---|---|
promote_topology |
validate_promotion runs before create_version; a later creation failure removes versions created by that attempt. |
retire_version |
All retirement gates precede delete_version. The target is one lab-created Foundry version, not a logical topology version. |
enforce_and_meter_usage |
Placeholder 2 becomes one retry loop for rate/quota checks, charge calculation, and conditional meter replacement. It does not mutate Foundry versions. |
Before continuing, confirm that promotion, usage metering, and retirement have separate mutation boundaries and fail-closed controls.
Task 2: Build the virtual environment
- On Windows, run:
./scripts/setup.ps1
. ./.venv/Scripts/Activate.ps1
On macOS/Linux, run
bash scripts/setup.shandsource .venv/bin/activateinstead.
- Review
registry/fabrikam-review-topology-v2.4.0.yaml. It defines the three agents, instructions, logical versions, compatibility declarations, evaluation results, and approval evidence used in this exercise.
Task 3: Deploy Azure resources
- Check Foundry and Cosmos DB costs and lifecycle/data-plane access.
azdprovisions infrastructure; the application creates agent versions later. - Set
$azureRegionto an approved region that supports the required model and services. - Replace the example value
eastus2if needed.
Resource group: If your lab environment provides a precreated resource group, set
$resourceGroupNameto its name. Otherwise, leave$resourceGroupNameempty so the script creates a unique resource group in your subscription.
Note:
AZURE_DEV_USER_AGENTtags provisioning for attribution and is not exported to.env. Remove it afterward to avoid tagging unrelated commands.
- Run the following commands:
$azureRegion = 'eastus2'
$resourceGroupName = ''
$modelDeploymentName = 'gpt-5.4-mini'
$modelName = 'gpt-5.4-mini'
$modelVersion = '2026-03-17'
if ([string]::IsNullOrWhiteSpace($resourceGroupName)) {
$resourceGroupName = "rg-lab12-$((New-Guid).Guid.Substring(0, 8))"
az group create --name $resourceGroupName --location $azureRegion | Out-Null
}
az bicep build --file infra/main.bicep
$env:AZURE_DEV_USER_AGENT='microsoft_foundry_skill'
azd env new lab12-agent-lifecycle
azd env set AZURE_LOCATION $azureRegion
azd env set AZURE_RESOURCE_GROUP $resourceGroupName
azd env set FOUNDRY_MODEL_NAME $modelDeploymentName
azd env set FOUNDRY_MODEL_CATALOG_NAME $modelName
azd env set FOUNDRY_MODEL_VERSION $modelVersion
azd provision
azd env get-values | Out-File .env -Encoding utf8
Remove-Item Env:AZURE_DEV_USER_AGENT
Note: If provisioning fails, inspect the first Azure deployment error. Model or region availability, extension compatibility, quota, and role-assignment permissions are common causes. Correct the cause, then run
azd provisionagain.
- After provisioning succeeds, validate that
.envincludes the Foundry project endpoint and ID,FOUNDRY_MODEL_NAME,FOUNDRY_MODEL_VERSION, the Cosmos DB endpoint and container values, andUSAGE_IDENTITY_CLIENT_ID. The file contains resource identifiers and parameterized settings, not keys or tokens. - Assign your development identity the minimum Foundry role required to manage prompt agents at project scope and the Cosmos DB Built-in Data Contributor role at the provisioned account scope.
- Do not add keys, connection strings, or client secrets.
Network access for this lab: The Bicep template enables native public network access for Foundry and Cosmos DB so the local application can reach both data-plane endpoints. Microsoft Entra authentication and Azure RBAC are still required. After deployment, confirm public access on both resources and confirm that the Foundry default network action is Allow. Production environments should use an approved selected-network or private-endpoint design.
Task 4: Implement the solution
Each placeholder marks incomplete code. Copy each supplied snippet into its placeholder location, keep the LAB PLACEHOLDER comment, replace only the indicated incomplete line or block, and preserve the surrounding indentation.
Implement the promotion gate
- Open
src/lifecycle.pyand find LAB PLACEHOLDER 1 invalidate_promotion:
# LAB PLACEHOLDER 1: Replace this line with the Task 1 sample.
raise NotImplementedError("Complete validate_promotion in Task 1")
- Replace only the
raise NotImplementedErrorline beneath it with this code:
failures: list[str] = []
evaluation = manifest.get("evaluation") or {}
approval = manifest.get("approval") or {}
compliance = manifest.get("compliance") or {}
topology = manifest.get("topology") or {}
agent_specs = {
str(item.get("agent_id")): item
for item in topology.get("agents") or []
}
compatibility = topology.get("compatibility") or {}
if manifest.get("status") != "approved":
failures.append("manifest status is not approved")
if approval.get("status") != "approved":
failures.append("approval status is not approved")
if set(agent_specs) != {"scanner", "reviewer", "orchestrator"}:
failures.append("topology must contain scanner, reviewer, and orchestrator")
try:
scanner_schema = Version(str(compatibility["scanner_output_schema"]))
reviewer_requirement = SpecifierSet(
str(compatibility["reviewer_requires_scanner_schema"])
)
if scanner_schema not in reviewer_requirement:
failures.append("scanner output schema is incompatible with reviewer")
except (KeyError, TypeError, ValueError):
failures.append("scanner-to-reviewer compatibility evidence is invalid")
try:
reviewer_version = Version(str(agent_specs["reviewer"]["version"]))
orchestrator_requirement = SpecifierSet(
str(compatibility["orchestrator_requires_reviewer_version"])
)
if reviewer_version not in orchestrator_requirement:
failures.append("reviewer version is incompatible with orchestrator")
except (KeyError, TypeError, ValueError):
failures.append("reviewer-to-orchestrator compatibility evidence is invalid")
try:
if float(evaluation["precision"]) < float(evaluation["minimum_precision"]):
failures.append("precision is below its minimum")
except (KeyError, TypeError, ValueError):
failures.append("precision evidence is missing or invalid")
try:
if float(evaluation["recall"]) < float(evaluation["minimum_recall"]):
failures.append("recall is below its minimum")
except (KeyError, TypeError, ValueError):
failures.append("recall evidence is missing or invalid")
if compliance.get("material_change_assessment") is not True:
failures.append("material change assessment is missing")
technical_file = str(compliance.get("technical_file_reference", "")).strip()
if not technical_file or not Path(technical_file).is_file():
failures.append("technical file reference is missing or unavailable")
if failures:
raise PermissionError("Promotion denied: " + "; ".join(failures))
The gate collects all failures: required roles, compatibility, evaluation thresholds, explicit material-change approval, and an existing technical file. Invalid or missing evidence denies promotion before version creation.
- Leave
promote_topologyunchanged. It applies the gate once for all three agents and returns the logical-to-Foundry version map.
Enforce and meter usage atomically
- Open
src/usage.pyand find LAB PLACEHOLDER 2 inenforce_and_meter_usage:
# LAB PLACEHOLDER 2: Replace this line with the Task 2 sample.
raise NotImplementedError("Complete enforce_and_meter_usage in Task 2")
- Replace only the
raise NotImplementedErrorline beneath it with this code:
controls = policy["usage_governance"]
token_count = int(request["token_count"])
if token_count <= 0:
raise ValueError("token_count must be positive")
tenant_id = str(request["tenant_id"])
cost_center = str(request["cost_center"])
if not tenant_id or not cost_center:
raise ValueError("tenant_id and cost_center are required")
now = datetime.now(timezone.utc)
month = now.strftime("%Y-%m")
rate_window = now.strftime("%Y-%m-%dT%H:%M")
allocation_key = f"{tenant_id}:{cost_center}"
meter_id = f"{allocation_key}:{month}"
for _ in range(max_attempts):
try:
meter = container.read_item(item=meter_id, partition_key=allocation_key)
except CosmosResourceNotFoundError:
meter = {
"id": meter_id,
"allocation_key": allocation_key,
"tenant_id": tenant_id,
"cost_center": cost_center,
"month": month,
"monthly_tokens": 0,
"request_count": 0,
"estimated_charge": 0.0,
"rate_window": rate_window,
"rate_window_requests": 0,
}
try:
container.create_item(meter)
except CosmosResourceExistsError:
continue
meter = container.read_item(item=meter_id, partition_key=allocation_key)
window_requests = (
int(meter["rate_window_requests"])
if meter["rate_window"] == rate_window
else 0
)
if window_requests + 1 > int(controls["requests_per_minute"]):
raise PermissionError("Per-minute request limit exceeded")
new_monthly_tokens = int(meter["monthly_tokens"]) + token_count
if new_monthly_tokens > int(controls["monthly_token_quota"]):
raise PermissionError("Monthly token quota exceeded")
charge = token_count / 1000 * float(controls["price_per_1000_tokens"])
meter.update(
{
"monthly_tokens": new_monthly_tokens,
"request_count": int(meter["request_count"]) + 1,
"estimated_charge": round(
float(meter["estimated_charge"]) + charge,
6,
),
"rate_window": rate_window,
"rate_window_requests": window_requests + 1,
"last_metered_at": now.isoformat(),
"currency": str(controls["currency"]),
"agent_id": str(request["agent_id"]),
"agent_version": str(request["agent_version"]),
}
)
try:
saved = container.replace_item(
item=meter_id,
body=meter,
etag=meter["_etag"],
match_condition=MatchConditions.IfNotModified,
)
return {
"decision": "allow",
"meter_id": saved["id"],
"allocation_key": allocation_key,
"monthly_tokens": saved["monthly_tokens"],
"monthly_token_quota": int(controls["monthly_token_quota"]),
"rate_window_requests": saved["rate_window_requests"],
"requests_per_minute": int(controls["requests_per_minute"]),
"estimated_charge": saved["estimated_charge"],
"currency": saved["currency"],
"metered_at": saved["last_metered_at"],
}
except CosmosHttpResponseError as error:
if error.status_code != 412:
raise
raise RuntimeError("Usage meter update conflicted too many times")
The limit checks occur before the item mutation. IfNotModified binds the replacement to the _etag read by this attempt; a concurrent writer causes HTTP 412 and a retry instead of a lost increment. The 60 requests per UTC minute, 1,000,000 monthly tokens, and USD 0.005 per 1,000 tokens remain parameterized in policy/promotion-policy.yaml. They are application governance values, not Azure model prices.
Check the completed code
- Run the following checks:
python -m py_compile src/main.py src/lifecycle.py src/usage.py scripts/preflight.py
python scripts/preflight.py
Get-ChildItem src -Filter *.py | Select-String -Pattern 'NotImplementedError'
Preflight must end with READY (local), and the final command must return no matches. Project and Cosmos configuration can remain INFO before provisioning.
Task 5: Run the solution
- Record the Foundry inventory before promotion:
python -m src.main inventory
In a fresh project, expect an empty list. Retain the inventory as your baseline.
- Promote the approved topology:
$promotion = python -m src.main promote | ConvertFrom-Json
$promotion
$scannerVersion = $promotion.versions.scanner.foundry_version
$reviewerVersion = $promotion.versions.reviewer.foundry_version
$orchestratorVersion = $promotion.versions.orchestrator.foundry_version
- Inspect release
2.4.0and itsscanner,reviewer, andorchestratorlogical-to-Foundry version mappings:
$promotion | ConvertTo-Json -Depth 10
$scannerVersion
$reviewerVersion
$orchestratorVersion
Expected logical versions are scanner: 1.2.0, reviewer: 1.1.0, and orchestrator: 1.3.0. Each Foundry version value must be present; do not assume it matches the logical semantic version.
- Inventory the project again:
python -m src.main inventory
- Confirm all three captured versions appear in inventory. In the Foundry portal, select the project > Agents and match each version to its PowerShell variable.
If version creation fails, promotion attempts rollback in reverse creation order. When rollback succeeds, the original promotion exception and traceback are preserved. When any cleanup fails, the terminal error reports both the original promotion error and every exact agent:version cleanup failure; retain those targets for reconciliation.
Test a denied promotion
- Snapshot inventory before testing denials:
$inventoryBeforeDeniedPromotion = python -m src.main inventory | ConvertFrom-Json
$inventoryBeforeDeniedPromotion | ConvertTo-Json -Depth 10
-
In
registry/fabrikam-review-topology-v2.4.0.yaml, changeevaluation.precisionfrom0.96to0.94, belowminimum_precision: 0.95. -
Attempt promotion without overwriting
$promotion:
python -m src.main promote
Expect PermissionError: Promotion denied: precision is below its minimum, before any version creation.
- Capture inventory after the denial:
$inventoryAfterDeniedPromotion = python -m src.main inventory | ConvertFrom-Json
$inventoryAfterDeniedPromotion | ConvertTo-Json -Depth 10
- Compare the snapshots:
Compare-Object ($inventoryBeforeDeniedPromotion | ConvertTo-Json -Depth 10) ($inventoryAfterDeniedPromotion | ConvertTo-Json -Depth 10)
- Expect no differences. Refresh all three portal version lists to confirm no new versions, then restore
precision: 0.96. - Under
topology.compatibility, changereviewer_requires_scanner_schemafrom'>=2.0,<3.0'to'>=3.0,<4.0'. - Attempt promotion:
python -m src.main promote
- Expect
PermissionError: Promotion denied: scanner output schema is incompatible with reviewer. - Capture inventory again:
$inventoryAfterCompatibilityDenial = python -m src.main inventory | ConvertFrom-Json
- Compare with the pre-denial snapshot:
Compare-Object ($inventoryBeforeDeniedPromotion | ConvertTo-Json -Depth 10) ($inventoryAfterCompatibilityDenial | ConvertTo-Json -Depth 10)
- Expect no differences, then restore
reviewer_requires_scanner_schemato'>=2.0,<3.0'.
Reconcile versions left by a failed rollback
Use this administrative operation only for exact agent:version targets reported in a promotion cleanup failure. Never pass a version from the successful $promotion mapping.
python -m src.main reconcile --orphan-version scanner:<foundry-version> --orphan-version reviewer:<foundry-version> --confirm-delete-orphans
The result separates deleted from already_absent. Run the same command again and confirm that every target moves to already_absent, demonstrating idempotent reconciliation. If any delete fails, the error reports versions already deleted, versions already absent, and every remaining cleanup failure so the command can be safely retried.
Verify metering and usage controls
- Run the metering command once:
python -m src.main meter
In the terminal output, confirm:
decisionisallow.allocation_keyissynthetic-tenant-a:FAB-SEC-042.monthly_tokensincreased by1250andmonthly_token_quotais1000000.rate_window_requestsincreased by1andrequests_per_minuteis60.estimated_chargeincreased by0.00625,currencyisUSD, andmetered_atcontains a UTC timestamp.
- Run it a second time:
python -m src.main meter
For the first two requests in one UTC minute, expect monthly_tokens: 2500, rate_window_requests: 2, and estimated_charge: 0.0125. Otherwise, compare increments of 1250 tokens and USD 0.00625 in the same monthly meter; the rate count resets when the UTC minute changes.
- In the Azure portal, open Cosmos DB > Data Explorer > lifecycle > usage-meters > Items. Open the item beginning
synthetic-tenant-a:FAB-SEC-042and confirm that partition key. Recordmonthly_tokens,request_count,rate_window_requests,estimated_charge, and_ts; keep the item open for denial tests.
Test the per-minute request limit
- In
policy/promotion-policy.yaml, changerequests_per_minutefrom60to1. Wait for a new UTC minute if you have already metered a request in the current one. - Run the first request:
python -m src.main meter
- Expect
decision: allowandrate_window_requests: 1. Refresh and reopen the Data Explorer item; record its counters, charge, and_ts. - Run a second request before the UTC minute changes:
python -m src.main meter
- Expect
PermissionError: Per-minute request limit exceeded. Refresh the item and confirm all five recorded values are unchanged. Restorerequests_per_minute: 60.
Test the monthly token quota
- In
assets/usage-request.json, changetoken_countfrom1250to1000001. - Run:
python -m src.main meter
- Expect
PermissionError: Monthly token quota exceeded. Refresh the item and confirm the five recorded values remain unchanged. Verifyscanner, logical version1.2.0, tenantsynthetic-tenant-a, and cost centerFAB-SEC-042, with no credentials or request content. Restoretoken_count: 1250.
Meter counters are post-update values. Estimated charges are application allocations, not Azure invoices; denied requests must leave counters and charge unchanged.
Task 6: Validate the implementation
-
Use the Foundry portal and SDK output to verify that all three prompt-agent versions in the promoted release exist and match
$scannerVersion,$reviewerVersion, and$orchestratorVersion. -
Run inventory again and compare it with the original evidence.
-
Review the technical file, topology compatibility declarations, and manifest diff as the approval record.
-
For retirement practice, use the disposable
scannerversion created by this lab. -
Open
assets/retirement-request.jsonand replace itsversionvalue with the value shown in$scannerVersion. -
Review the request and confirm that it records at least 90 days between deprecation and deletion, zero consumers, zero pins, archive completion, deprecation registration, and governance approval.
-
Immediately before running a retirement command, confirm that the command targets agent
scannerand the exact disposable version stored in$scannerVersion. Stop if either value differs. -
Test each gate separately: consumers or pins set to
1, archive or deprecation registration set tofalse, approval set topending, a mismatched request version, a shorter notice interval, and a future deletion date. Keep the CLI target fixed to this lab's$scannerVersion. After each denial and inventory check below, restore the changed field before testing the next gate. -
For each change, run:
python -m src.main retire --agent-name scanner --agent-version $scannerVersion --confirm-delete-version
- Confirm that the terminal shows
Retirement deniedand names the failed gate. - Run inventory after each denial and confirm that the
scannerversion in$scannerVersionstill exists. - Restore the approved synthetic request.
- Run:
python -m src.main retire --agent-name scanner --agent-version $scannerVersion --confirm-delete-version
-
Confirm that the success output shows
retired: trueand repeats the enforced gate evidence. -
Run inventory again and confirm that
$scannerVersionis absent while thereviewerandorchestratorversions remain. This demonstrates component retirement within a topology. -
Retain the Cosmos DB snapshots and both usage-denial messages alongside the release mapping, approval record, and retirement evidence.
Optional challenge: Block an incompatible promotion
Create a synthetic reviewer version whose output contract is incompatible with an active orchestrator consumer.
Expected output: Promotion is blocked and the evidence names the failed compatibility rule and affected consumer.
Failure investigation: Attempt to retire a version still referenced by the topology and explain the lifecycle-policy rejection.
Task 7: Review the design
- Answer these questions:
- Why is a prompt hash part of an agent version?
- Which changes require manual or customer approval?
- Why should deprecation and consumer migration precede deletion of one topology component?
- What proves a retired version has no remaining consumers?
- Why must quota enforcement and meter increments use one conditional update?
- How does chargeback evidence differ from an Azure invoice?
Task 8: Clean up
Remove Azure resources
- Reconcile any exact orphan targets reported by failed promotion rollback, then delete any remaining
scanner,reviewer, andorchestratorversions created by this lab. - Run
azd down --purge. - Confirm that the Foundry project, model deployment, and Cosmos DB account are removed.
- Keep only synthetic manifests and evidence; remove
.envand local caches.
Deactivate the virtual environment
- Run this command in every terminal where
(.venv)appears in the prompt:
deactivate
- Confirm that
(.venv)no longer appears before leaving the working directory.
Summary
You used real Foundry and Cosmos DB interfaces to govern a three-agent topology release, immutable prompt-agent versions, quotas, rate limits, usage allocation, estimated chargeback, and fail-closed retirement.