Copilot Agent Inventory - Flow Setup Guide¶
Overview¶
Step-by-step guide for building the Power Automate flow that schedules the daily Copilot Agent Inventory discovery scan and records each run for audit purposes.
This guide covers one flow:
- CAI-DailyDiscovery — daily scheduled, tenant-wide discovery scan that executes the scanner, persists the inventory to Dataverse, and writes a run record.
Important: These are manual build instructions. This solution does not include exported flow JSON, connection references, or environment-variable exports. Build the flow in the Power Automate designer following the steps below. This keeps the solution free of fragile cross-file references and lets administrators adapt the flow to their environment.
Persistence contract: The discovery scanner emits a combined JSON file (via
--output) and does not itself write to Dataverse. The scanner emits option-set fields as LABEL strings (e.g."Paid Copilot","Reconciled (multi-source)"); this flow converts each label to its Dataverse option-set integer via Compose actions before writing (see Step 7a below). This flow reads the scanner JSON output, iterates overagents[]to reconcile each record intofsi_copilotagent(including all new package, owner, and entitlement fields), and then writes exactly onefsi_caiscanrunrun-ledger row (Step 8). The validated connector pattern is List rows -> Add when missing -> capture the primary GUID -> Update a row by GUID. All Dataverse persistence is the responsibility of this flow.
Prerequisites¶
Before creating the flow, confirm you have:
- Azure Automation Account (or an Azure Function / hosted runner) with:
- The discovery scanner (
scripts/discover_agents.py) deployed as a runnable job, running under a managed identity (system- or user-assigned). - Python 3.9+ and the packages in
scripts/requirements.txtavailable to the job. - Dataverse environment with the CAI schema deployed:
- 9 tables:
fsi_copilotagent,fsi_caienvironment,fsi_caiagentfeature,fsi_caiauthshare,fsi_caibillingentitlement,fsi_caiusagesignal,fsi_caiworkiqstate,fsi_caicompliancestate,fsi_caiscanrun. - Scanner service principal (read-only) with the roles and scopes in
prerequisites.md — environment enumeration + per-environment
bot/botcomponentread only. The scanner emits JSON and does not write to Dataverse. - Flow-writer identity — the Power Automate Dataverse connection this flow uses holds Create / Write on the CAI tables in the governance environment. This is the only identity that writes the inventory (see the three-identity split in prerequisites.md).
- Microsoft Teams channel and/or an email distribution list for run notifications (optional).
- Connection references bound in Power Automate (create these in the designer when you add each action; names below are a suggested convention):
fsi_cr_dataverse_copilotagentinventory(Dataverse)fsi_cr_teams_copilotagentinventory(Microsoft Teams, optional)- An Azure Automation (or Azure Function) connection.
Flow: CAI-DailyDiscovery¶
Purpose¶
Runs daily, executes the discovery scanner across the tenant, writes the normalized inventory to the CAI Dataverse tables, and records a run summary. A durable run record supports compliance with the record-keeping expectations of FINRA Rule 4511 and SEC Rule 17a-3/17a-4 — the run summary is written regardless of whether notification succeeds.
Step 1: Create the Flow¶
- Go to make.powerautomate.com.
- Select your governance environment.
- Click Create > Scheduled cloud flow.
- Name:
CAI - Copilot Agent Discovery (Daily). - Set schedule:
- Repeat every: 1 Day.
- At: 5:00 AM.
- Time zone: UTC.
- Click Create.
Cadence note: ARG inventory freshness is roughly 15 minutes, so a daily scan is well within data-freshness limits. Adjust the cadence to your examination and change-tracking needs.
Step 2: Initialize Variables¶
Add these Initialize variable actions immediately after the trigger. Replace
the placeholders ({{...}}) with your organization's values.
| Variable | Type | Default Value | Description |
|---|---|---|---|
RunStartedAt |
String | utcNow() |
Run-start timestamp captured at flow start (written to fsi_startedat). Initialize this first, before Step 3 — the scanner JSON does not carry its own run timing. |
GovernanceDataverseUrl |
String | https://governance.crm.dynamics.com |
Dataverse environment hosting the CAI tables |
TenantId |
String | {{TENANT_DOMAIN}} |
Microsoft Entra ID tenant identifier |
Agent365Mode |
String | absent |
Agent 365 / Layer 4 mode. Accepted values: present, absent (default), auto. Passed to the scanner as --agent365 <Agent365Mode>. Leave at absent unless the operator has authoritatively scoped in Agent 365 (see prerequisites.md). |
SubscriptionId |
String | {{AZURE_SUBSCRIPTION}} |
Azure subscription containing the Automation Account |
ResourceGroup |
String | {{RESOURCE_GROUP}} |
Resource group with the Automation Account |
AutomationAccount |
String | {{AUTOMATION_ACCOUNT}} |
Azure Automation Account name |
TeamsGroupId |
String | {{TEAMS_GROUP_ID}} |
Teams group ID for run notifications (optional) |
TeamsChannelId |
String | {{TEAMS_CHANNEL_ID}} |
Teams channel ID for run notifications (optional) |
UnmappedRows |
Array | [] |
Sanitized keyless-row and unknown-Choice-label errors collected across agent and run-ledger persistence |
Agent365Modeis an operator declaration. Validate it to exactly one ofpresent/absent/autobefore Step 3 (add a Condition or a small Switch that terminates on any other value). The scanner also enforces the value, but validating in the flow keeps a misconfigured variable from starting a job. Do not prompt interactively — the mode is chosen here, in the flow variable, once.Integrated scanner additional variables (add when using
--registry-export):RegistryExportPath(String — path to the XLSX or CSV registry export),ColumnMapPath(String — defaulttemplates/registry-columnmap.sample.json),AsOfDateTime(String — ISO-8601 UTC as-of timestamp for the export, e.g.2026-07-21T18:00:00Z).No secrets in variables. The scanner authenticates with its managed identity. Do not place client secrets in flow variables; if a dev-only secret is unavoidable, the scanner reads it from Key Vault at runtime.
Step 3: Execute the Discovery Scanner¶
- Add action: Azure Automation > Create job (or the equivalent Azure Function action).
- Configure:
- Subscription:
SubscriptionIdvariable. - Resource Group:
ResourceGroupvariable. - Automation Account:
AutomationAccountvariable. - Runbook / job: the wrapper that invokes the integrated scanner. For the
full BI-ready combined output (agents[] + registryCorrelation +
entitlementResolution):
Set
python scripts/discover_agents.py \ --tenant-id <TenantId> \ --auth-mode managed-identity \ --agent365 <Agent365Mode> \ --registry-export <RegistryExportPath> \ --columnmap <ColumnMapPath> \ --as-of <AsOfDateTime> \ --resolve-entitlement \ --output scan.jsonAgent365Modetoabsent(default) to defer Layer 4; the run still inventories available agents and marks the Package APIDeferred(never an observed zero). ARG can classify Agent Builder agents throughcreatedIn; Layer 2 does not supply that field. Set the mode topresentto attempt the Package API, orautoto license-probe first. Omit--registry-export(and related flags) to run without registry correlation; the output is backward-compatible when these flags are absent. - Rename action:
Create_Discovery_Job.
Step 4: Wait for Job Completion¶
- Add action: Azure Automation > Wait for job.
- Configure:
- Job ID:
Create_Discovery_JoboutputjobId. - Timeout: 7200 seconds (2 hours) — raise for very large tenants.
- Polling interval: 60 seconds.
- Rename action:
Wait_For_Job.
Step 5: Get Job Output¶
- Add action: Azure Automation > Get job output.
- Configure: Job ID = the same
jobId. - Rename action:
Get_Job_Output.
Step 6: Parse the Scan Summary¶
- Add action: Data Operations > Parse JSON.
- Content:
Get_Job_Outputoutput (the full scanner JSON — top-level keys aresummary,agents,features, andauthShares). - Schema (matches the full
discover_agents.py scan_allcombined output;runIdand all counts live insidesummary,agentsis top-level):
{
"type": "object",
"properties": {
"summary": {
"type": "object",
"properties": {
"runId": { "type": "string" },
"status": { "type": "string" },
"environmentEnumeration": {
"type": "object",
"properties": {
"status": { "type": "string" },
"environmentCount": { "type": "integer" },
"dataverseEnvironmentCount": { "type": "integer" },
"skippedNoDataverseCount": { "type": "integer" },
"httpStatus": { "type": ["integer", "null"] },
"reason": { "type": "string" }
}
},
"argLayer": {
"type": "object",
"properties": {
"status": { "type": "string" },
"agentCount": { "type": "integer" },
"httpStatus": { "type": ["integer", "null"] }
}
},
"environmentFailures": {
"type": "array",
"items": { "type": "object" }
},
"argAgentCount": { "type": "integer" },
"scannedAgentCount": { "type": "integer" },
"coreAgentCount": { "type": "integer" },
"featureCount": { "type": "integer" },
"authShareCount": { "type": "integer" },
"environmentCount": { "type": "integer" },
"packageNewRowCount": { "type": "integer" },
"packageScanTruncated": { "type": "boolean" },
"reconciliation": {
"type": "object",
"properties": {
"in_arg_only": { "type": "array" },
"in_dataverse_only": { "type": "array" },
"in_both": { "type": "array" }
}
},
"registryCorrelation": {
"type": "object",
"properties": {
"registryRowCount": { "type": "integer" },
"matched": { "type": "integer" },
"unmatchedRegistryRows": { "type": "integer" },
"ambiguousNameSkipped": { "type": "integer" },
"invalidDateWarnings": { "type": "integer" },
"status": { "type": "string" }
}
},
"entitlementResolution": {
"type": "object",
"properties": {
"ownersConsidered": { "type": "integer" },
"paidCount": { "type": "integer" },
"chatOnlyCount": { "type": "integer" },
"unknownCount": { "type": "integer" },
"status": { "type": "string" }
}
},
"agent365": {
"type": "object",
"properties": {
"requestedMode": { "type": "string" },
"resolvedState": { "type": "string" },
"resolutionSource": { "type": "string" },
"detectionConfidence": { "type": "string" },
"licenseProbeAttempted": { "type": "boolean" },
"packageApiAttempted": { "type": "boolean" },
"layerStatus": { "type": "string" },
"httpStatus": { "type": ["integer", "null"] },
"errorCode": { "type": ["string", "null"] },
"errorSubcode": { "type": ["string", "null"] },
"reason": { "type": ["string", "null"] },
"packagesObserved": { "type": ["integer", "null"] },
"packageNewRowCount": { "type": ["integer", "null"] },
"pagingTruncated": { "type": "boolean" }
}
},
"coverageScope": {
"type": "object",
"properties": {
"layers": {
"type": "object",
"properties": {
"arg": { "type": "string" },
"environmentDataverse": { "type": "string" },
"packageApi": { "type": "string" },
"registry": { "type": "string" },
"entitlement": { "type": "string" }
}
},
"authoritativeFor": { "type": "array", "items": { "type": "string" } },
"limitations": { "type": "array", "items": { "type": "string" } },
"warning": { "type": "string" }
}
}
}
},
"agents": {
"type": "array",
"items": { "type": "object" }
},
"features": { "type": "array" },
"authShares": { "type": "array" }
}
}
- Rename action:
Parse_Summary.
Scan-completeness signals (read before persisting). The scanner reports whether the run is trustworthy through four summary fields. Use them to decide whether to alert (Step 9) — a partial or authorization-blocked scan must never be treated as a clean, empty tenant:
summary.status—Complete/Incomplete/Failed(overall).summary.environmentEnumeration.status—Success/Failed/Dry Run. AFailedvalue means the environment list itself could not be read (for example a 401/403), so zero environments is a failure, not an empty tenant.summary.argLayer.status—Available/Unavailable/Failed/Disabled.Failedis distinct fromAvailablewithagentCount: 0(a genuine zero).summary.environmentFailures[]— one record per per-environment coverage gap (environmentId,stage,httpStatus,reason). An empty array is a clean run.Parse
summary.agent365andsummary.coverageScopeon every run. They are always present, and Step 8 persists them tofsi_caiscanrun:
summary.agent365.resolvedState—Present/Absent/NotDetected/Inconclusive.summary.agent365.layerStatus—Full/Deferred/Unsupported/Partial/Failed/Dry Run.Deferredand heuristicNotDetectedare informational, not failures (see Step 9). ADeferredLayer 4 is never "zero Agent Builder agents"; it means the package catalog was not observed. Agent Builder classification still comes from ARG whencreatedInis available; Layer 2 inventory alone leaves the authoring surface unknown.- Null vs zero for package counts.
summary.agent365.packagesObservedandpackageNewRowCountarenullwhen the Package API was not observed (deferred / not attempted) and0when it was attempted and returned an empty catalog. Preserve the distinction when mapping to Dataverse in Step 8 — writenull, not0, for a deferred layer.summary.coverageScope.layersnames each layer's status (layers.arg/layers.environmentDataverse/layers.packageApi/layers.registry/layers.entitlement), alongsidecoverageScope.authoritativeFor,coverageScope.limitations, and acoverageScope.warningthat aDeferred/NotDetectedLayer 4 is not an authoritative Agent Builder catalog.
Step 7: Persist Agent Records to Dataverse¶
The scanner emits each discovered agent as an object in agents[]. Use an
Apply to each action to reconcile every agent row into fsi_copilotagent.
Connector behavior validated live. The Dataverse connector's Update a row action executes an update against its Row ID. Supplying an alternate-key value does not create a missing row; the observed connector returned HTTP 404. Use the lookup/create/GUID-update sequence below rather than treating Update a row as an alternate-key upsert.
- Before the loop, initialize a String variable named
AgentRowIdand a Boolean variable namedIsUnmappedLabel. - Add action: Control > Apply to each.
- Select output: expression
body('Parse_Summary')?['agents'](the top-levelagentsarray from the full scanner output;agentsis not nested insidesummary). - Set Apply to each concurrency to Off (or degree
1). The flow uses the sharedAgentRowIdvariable, so parallel iterations can update the wrong row. - Inside the loop, reset
AgentRowIdto an empty string andIsUnmappedLabeltofalse. Complete every Step 7a Choice mapping before any List rows, Add, or Update action. If any mapping Compose returns-1, quarantine the row and skip the persistence branches. -
Add a Condition action to select the correct key using this deterministic precedence:
-
Branch A — environment-scoped rows (PRIMARY) — when both
fsi_agentidandfsi_environmentidare populated:- Add Dataverse > List rows for
fsi_copilotagent. - Filter on both key columns:
fsi_agentid eq '<current fsi_agentid>' and fsi_environmentid eq '<current fsi_environmentid>'. Set Row count to1and select onlyfsi_copilotagentid. - If the returned
valuearray is non-empty, setAgentRowIdtofirst(body('List_Agent_By_Env_Key')?['value'])?['fsi_copilotagentid']. - Otherwise, add Dataverse > Add a new row with:
fsi_nameandfsi_agentnameset to the agent name, falling back tofsi_agentid;fsi_agentidandfsi_environmentidfrom the current item;fsi_lastscannedatset toutcNow();fsi_runidfrom the current item. Then setAgentRowIdfrom the created row'sfsi_copilotagentid.
- Add Dataverse > List rows for
-
Branch B — package-only rows (FALLBACK) — when Branch A does not apply and
fsi_packageidis populated:- Add Dataverse > List rows for
fsi_copilotagent. - Filter on
fsi_packageid eq '<current fsi_packageid>'. Set Row count to1and select onlyfsi_copilotagentid. - If the returned
valuearray is non-empty, setAgentRowIdto the first row'sfsi_copilotagentid. - Otherwise, add Dataverse > Add a new row with:
fsi_nameandfsi_agentnameset to the package display name, falling back tofsi_packageid;fsi_agentidandfsi_packageidset to the package ID;fsi_lastscannedatset toutcNow();fsi_runidfrom the current item. Then setAgentRowIdfrom the created row'sfsi_copilotagentid.
- Do not supply
fsi_environmentid; package-only rows have no environment scope.
- Add Dataverse > List rows for
-
Branch C — unpersistable rows (ERROR) — when neither condition is true, add a Terminate action (status: Failed) or append the row to an error collection variable for post-run review. Do not add a generic Add a new row action without an idempotency key.
Both alternate keys must reach Active before scheduled persistence begins.
The keys protect uniqueness, but the connector lookup and create operations
are not atomic. Configure trigger concurrency to allow only one active flow
run; overlapping runs can cause one run to fail on an alternate-key collision
even though duplicate rows are blocked.
Existing v0.4 preview deployments: fresh deployments define
fsi_environmentidas optional because package-only rows have no environment. The schema deployer skips metadata for columns that already exist. If an earlier preview deployment still shows Environment ID as Business Required, change its Requirement to Optional in the Dataverse table designer and publish before authoring Branch B.
- After Branch A or B sets
AgentRowId, add Dataverse > Update a row. Table:fsi_copilotagent. Set Row ID tovariables('AgentRowId'). - Map the columns below. Use
items('Apply_to_each')to reference the current agent object. All column names are Dataverse logical names (lowercase, no underscores between words).
| Flow Expression | Dataverse Column (logical) | Type | Notes |
|---|---|---|---|
coalesce(items('Apply_to_each')?['fsi_agentname'], items('Apply_to_each')?['fsi_agentid'], items('Apply_to_each')?['fsi_packageid']) |
fsi_name |
String | Required Dataverse primary name; also set during Add a new row |
coalesce(items('Apply_to_each')?['fsi_agentid'], items('Apply_to_each')?['fsi_packageid']) |
fsi_agentid |
String | Agent ID; package-only rows use the package ID. PRIMARY key component of fsi_AgentEnvKey for Branch A |
items('Apply_to_each')?['fsi_environmentid'] |
fsi_environmentid |
String | PRIMARY key component of fsi_AgentEnvKey; present for environment-scoped and package-enriched rows (Branch A); absent for package-only rows (Branch B) |
coalesce(items('Apply_to_each')?['fsi_agentname'], items('Apply_to_each')?['fsi_agentid'], items('Apply_to_each')?['fsi_packageid']) |
fsi_agentname |
String | Required display name |
utcNow() |
fsi_lastscannedat |
DateTime | Required refresh timestamp; update on every run |
items('Apply_to_each')?['fsi_runid'] |
fsi_runid |
String | Correlates the current agent state to fsi_caiscanrun |
items('Apply_to_each')?['fsi_agenttype'] |
fsi_agenttype |
Choice | Agent type; convert label to integer — see Step 7a |
items('Apply_to_each')?['fsi_createdin'] |
fsi_createdin |
Choice | Platform origin; convert label to integer — see Step 7a |
items('Apply_to_each')?['fsi_discoverysource'] |
fsi_discoverysource |
Picklist | Discovery layer |
items('Apply_to_each')?['fsi_packageid'] |
fsi_packageid |
String | Package API id (P_...); FALLBACK key for fsi_PackageKey alternate key; used only when fsi_agentid or fsi_environmentid is absent (Branch B — package-only rows) |
items('Apply_to_each')?['fsi_packagetype'] |
fsi_packagetype |
String | microsoft / external / shared / custom |
items('Apply_to_each')?['fsi_elementtypes'] |
fsi_elementtypes |
Memo | JSON array (Bots / DeclarativeAgent / CustomEngineAgent) |
items('Apply_to_each')?['fsi_isblocked'] |
fsi_isblocked |
Boolean | Package blocked flag |
items('Apply_to_each')?['fsi_packageversion'] |
fsi_packageversion |
String | Package version |
items('Apply_to_each')?['fsi_assetid'] |
fsi_assetid |
String | Package asset id |
items('Apply_to_each')?['fsi_publisher'] |
fsi_publisher |
String | Publisher name |
items('Apply_to_each')?['fsi_supportedhosts'] |
fsi_supportedhosts |
Memo | JSON array of host surfaces |
items('Apply_to_each')?['fsi_availableto'] |
fsi_availableto |
Choice | Availability scope (None / Some / All); convert label to integer — see Step 7a |
items('Apply_to_each')?['fsi_deployedto'] |
fsi_deployedto |
Choice | Deployment scope (None / Some / All); convert label to integer — see Step 7a |
items('Apply_to_each')?['fsi_manifestid'] |
fsi_manifestid |
String | Manifest id |
items('Apply_to_each')?['fsi_manifestversion'] |
fsi_manifestversion |
String | Manifest version |
items('Apply_to_each')?['fsi_ownerupn'] |
fsi_ownerupn |
String | Owner UPN (registry-sourced; never the creator) |
items('Apply_to_each')?['fsi_ownerid'] |
fsi_ownerid |
String | Owner Entra object GUID |
items('Apply_to_each')?['fsi_createdon'] |
fsi_createdon |
DateTime | Registry-recorded creation date |
items('Apply_to_each')?['fsi_ownersource'] |
fsi_ownersource |
Picklist | Owner data provenance |
items('Apply_to_each')?['fsi_ownermatchconfidence'] |
fsi_ownermatchconfidence |
Picklist | Exact / Heuristic / Unmatched |
items('Apply_to_each')?['fsi_ownerasofdatetime'] |
fsi_ownerasofdatetime |
DateTime | Export as-of timestamp (staleness signal) |
items('Apply_to_each')?['fsi_ownerentitlement'] |
fsi_ownerentitlement |
Picklist | Paid Copilot / Copilot Chat Only / Unknown |
items('Apply_to_each')?['fsi_ownerentitlementevidence'] |
fsi_ownerentitlementevidence |
Memo | Service-plan GUIDs as JSON array (no PII) |
Column naming: always use Dataverse logical names (lowercase, no underscores between words) in flow column mappings — for example
fsi_agentid,fsi_ownerupn,fsi_packagetype. See dataverse-schema.md for the authoritative list.
- Configure Run after:
Parse_Summary— set to Succeeded. - Rename the loop
Persist_Agent_Recordsand the common GUID update actionUpdate_Agent_Record. - After the loop, add
Validate_Agent_Persistence. IfUnmappedRowscontains any entry (from an unknown Choice label or a keyless row), send a sanitized notification and terminate as Failed before writing the scan-run ledger.
Step 7a — Label-to-Integer Conversion for Choice Fields¶
At Step 7.5, before any lookup or write, add a Compose action for each Choice
column to convert the scanner's label string to the Dataverse option-set integer.
The scanner emits labels; Dataverse requires integers. fsi_isblocked is a
Two-Options (Boolean) field — pass true / false directly.
| Choice Column (logical name) | Scanner Label | Dataverse Integer |
|---|---|---|
fsi_discoverysource |
Azure Resource Graph |
100000000 |
fsi_discoverysource |
Per-Environment Dataverse Scan |
100000001 |
fsi_discoverysource |
PPAC Reconciliation |
100000002 |
fsi_discoverysource |
Reconciled (multi-source) |
100000003 |
fsi_discoverysource |
Package Management API |
100000004 |
fsi_createdin |
Copilot Studio |
100000000 |
fsi_createdin |
Microsoft 365 Copilot Agent Builder |
100000001 |
fsi_createdin |
Unknown |
100000002 |
fsi_agenttype |
Standard |
100000000 |
fsi_agenttype |
Lite / Agent Builder |
100000001 |
fsi_agenttype |
Declarative Agent |
100000002 |
fsi_agenttype |
Classic V1 (excluded) |
100000003 |
fsi_agenttype |
Unknown |
100000004 |
fsi_agenttype |
Custom Engine Agent |
100000005 |
fsi_availableto |
None |
100000000 |
fsi_availableto |
Some |
100000001 |
fsi_availableto |
All |
100000002 |
fsi_deployedto |
None |
100000000 |
fsi_deployedto |
Some |
100000001 |
fsi_deployedto |
All |
100000002 |
fsi_ownerentitlement |
Paid Copilot |
100000000 |
fsi_ownerentitlement |
Copilot Chat Only |
100000001 |
fsi_ownerentitlement |
Unknown |
100000002 |
fsi_ownersource |
Dataverse Owner |
100000000 |
fsi_ownersource |
Agent Registry Export |
100000001 |
fsi_ownersource |
Unresolved |
100000002 |
fsi_ownermatchconfidence |
Exact |
100000000 |
fsi_ownermatchconfidence |
Heuristic |
100000001 |
fsi_ownermatchconfidence |
Unmatched |
100000002 |
Implementation pattern: use a nested
if(...)expression in each Compose. Returnjson('null')when an optional source label is empty, the listed integer for a known label, and-1for a non-empty unknown label. For example,Map_DiscoverySourcestarts with:
if(empty(items('Apply_to_each')?['fsi_discoverysource']), json('null'), if(equals(items('Apply_to_each')?['fsi_discoverysource'], 'Azure Resource Graph'), 100000000, ... -1))Compose outputs are evaluated independently for each loop iteration, so a null value cannot reuse an integer from the prior agent. After all mapping Composes, add a Condition that checks whether any output equals
-1. If so, setIsUnmappedLabeltotrue, append the row and offending label toUnmappedRows, and skip the key-selection/persistence branches. Reference the Compose outputs in the final Update a row by GUID mapping.A non-empty unknown label almost always indicates platform drift. Do not map it to
0, the first option, or null; those choices would record an incorrect or missing governance signal.Keyless / unmapped rows are never inserted. A row is persisted only when it resolves a valid alternate key (Branch A
fsi_AgentEnvKeyor Branch Bfsi_PackageKey) and every Choice label mapped to a known integer. Rows that hit Branch C (no key) or a-1mapping result (unmapped label) are routed to the error collection / Terminate path — never written with a generic Add a new row action and never written with a guessed default option value. This helps keep the inventory free of unkeyed duplicates and mislabelled governance signals.
Step 8: Write the single Run-Ledger Record (fsi_caiscanrun)¶
Why this runs before notification: the run record is written regardless of whether notification succeeds, supporting compliance with the record-keeping expectations of FINRA Rule 4511 and SEC Rule 17a-3.
After all agent rows are persisted (Step 7), reconcile exactly one row in the
canonical run-ledger table fsi_caiscanrun.
- Before the Scope, initialize a String variable named
ScanRunRowId. - Add a Scope named
Persist_And_Verify_Scan_Run. Inside it, complete every Step 8a Choice mapping, naming thefsi_statusComposeMap_RunStatus. Any unmapped label terminates before a scan-run row is created. - Add Dataverse > List rows for
fsi_caiscanrun. Filter onfsi_runid eq '<summary runId>', set Row count to1, and select onlyfsi_caiscanrunid. - Add a Condition:
- If a row exists, set
ScanRunRowIdtofirst(body('List_Scan_Run_By_RunId')?['value'])?['fsi_caiscanrunid']. - If no row exists, add Dataverse > Add a new row with:
fsi_nameandfsi_runidset tobody('Parse_Summary')?['summary']?['runId'];fsi_startedatset tovariables('RunStartedAt');fsi_statusset tooutputs('Map_RunStatus'). Then setScanRunRowIdfrom the created row'sfsi_caiscanrunid.
- Add Dataverse > Update a row for
fsi_caiscanrun. Set Row ID tovariables('ScanRunRowId'). - Map the columns below. All names are Dataverse logical names (lowercase, no underscores between words). Choice columns are converted label→integer in Step 8a (do not write raw label strings to Choice columns).
| Flow Expression | Dataverse Column (logical) | Type | Notes |
|---|---|---|---|
body('Parse_Summary')?['summary']?['runId'] |
fsi_runid |
String | Collision-resistant run identity (alternate-key column) |
variables('RunStartedAt') |
fsi_startedat |
DateTime | Run start captured at flow start (the scanner JSON carries no run timing) |
utcNow() |
fsi_completedat |
DateTime | Completion timestamp at write time |
body('Parse_Summary')?['summary']?['status'] |
fsi_status |
Choice | Overall run status (Complete / Incomplete / Failed / Dry Run) — Step 8a |
body('Parse_Summary')?['summary']?['environmentEnumeration']?['status'] |
fsi_environmentenumerationstatus |
Choice | Layer 1 environment enumeration — special map Success→Full / Failed→Failed / Dry Run→Dry Run — Step 8a |
length(coalesce(body('Parse_Summary')?['summary']?['environmentFailures'], json('[]'))) |
fsi_environmentfailurecount |
Integer | Count of per-environment coverage failures |
body('Parse_Summary')?['summary']?['environmentEnumeration']?['httpStatus'] |
fsi_environmentenumerationhttpstatus |
Integer (nullable) | Enumeration HTTP status (null on success) |
body('Parse_Summary')?['summary']?['environmentEnumeration']?['reason'] |
fsi_environmentenumerationreason |
String (nullable) | Sanitized enumeration failure reason |
body('Parse_Summary')?['summary']?['coverageScope']?['layers']?['environmentDataverse'] |
fsi_dataverselayerstatus |
Choice | Layer 2 (per-environment Dataverse) status — Step 8a |
body('Parse_Summary')?['summary']?['environmentCount'] |
fsi_environmentcount |
Integer | Environments enumerated this run |
body('Parse_Summary')?['summary']?['environmentEnumeration']?['dataverseEnvironmentCount'] |
fsi_dataverseenvironmentcount |
Integer | Environments in Layer 2 scope, including malformed candidates that surface as coverage failures |
body('Parse_Summary')?['summary']?['environmentEnumeration']?['skippedNoDataverseCount'] |
fsi_nodataverseenvironmentcount |
Integer | Environments explicitly classified without Dataverse; Layer 2 not applicable |
body('Parse_Summary')?['summary']?['scannedAgentCount'] |
fsi_dataversescannedagentcount |
Integer | Agents scanned through the Dataverse layer |
body('Parse_Summary')?['summary']?['agent365']?['requestedMode'] |
fsi_agent365requestedmode |
Choice | Present / Absent / Auto — Step 8a |
body('Parse_Summary')?['summary']?['agent365']?['resolvedState'] |
fsi_agent365resolvedstate |
Choice | Present / Absent / NotDetected / Inconclusive — Step 8a |
body('Parse_Summary')?['summary']?['agent365']?['resolutionSource'] |
fsi_agent365resolutionsource |
String | Write directly — CLI / Environment / DeprecatedAlias / Default / LicenseProbe / DryRun (no Switch) |
body('Parse_Summary')?['summary']?['agent365']?['detectionConfidence'] |
fsi_agent365detectionconfidence |
Choice | OperatorDeclared / Confirmed / Heuristic / Inconclusive / NotApplicable — Step 8a |
body('Parse_Summary')?['summary']?['agent365']?['layerStatus'] |
fsi_agent365layerstatus |
Choice | Full / Deferred / Unsupported / Partial / Failed / Dry Run — Step 8a |
body('Parse_Summary')?['summary']?['agent365']?['licenseProbeAttempted'] |
fsi_licenseprobeattempted |
Boolean | Pass true / false directly (no Switch) |
body('Parse_Summary')?['summary']?['coverageScope']?['layers']?['packageApi'] |
fsi_packageapilayerstatus |
Choice | Layer 4 (Package API) coverage status — Step 8a |
body('Parse_Summary')?['summary']?['agent365']?['packageApiAttempted'] |
fsi_packageapiattempted |
Boolean | Pass true / false directly (no Switch) |
body('Parse_Summary')?['summary']?['agent365']?['httpStatus'] |
fsi_packageapihttpstatus |
Integer (nullable) | Package-API HTTP status |
body('Parse_Summary')?['summary']?['agent365']?['errorCode'] |
fsi_packageapierrorcode |
String (nullable) | Sanitized Package-API error code |
body('Parse_Summary')?['summary']?['agent365']?['reason'] |
fsi_packageapireason |
Memo (nullable) | Sanitized Agent 365 resolution or Package-API reason (no token material) |
body('Parse_Summary')?['summary']?['agent365']?['packagesObserved'] |
fsi_packagecount |
Integer (nullable) | Map the raw value. null = not observed (deferred / not attempted); 0 = observed empty. Do not coalesce to 0. |
body('Parse_Summary')?['summary']?['agent365']?['packageNewRowCount'] |
fsi_packagenewrowcount |
Integer (nullable) | Same null-vs-zero rule as above |
body('Parse_Summary')?['summary']?['agent365']?['pagingTruncated'] |
fsi_packagescantruncated |
Boolean | Pass true / false directly |
body('Parse_Summary')?['summary']?['coverageScope']?['layers']?['arg'] |
fsi_arglayerstatus |
Choice | Layer 1 (ARG) coverage status — Step 8a |
body('Parse_Summary')?['summary']?['argAgentCount'] |
fsi_argagentcount |
Integer | Agents discovered through the ARG layer |
body('Parse_Summary')?['summary']?['argLayer']?['httpStatus'] |
fsi_arghttpstatus |
Integer (nullable) | ARG query HTTP status |
body('Parse_Summary')?['summary']?['coreAgentCount'] |
fsi_coreagentcount |
Integer | Total agent rows emitted to the canonical agent table, including package-only rows |
body('Parse_Summary')?['summary']?['featureCount'] |
fsi_featurecount |
Integer | Feature rows written this run |
body('Parse_Summary')?['summary']?['authShareCount'] |
fsi_authsharecount |
Integer | Auth/share rows recorded this run |
body('Parse_Summary')?['summary']?['coverageScope']?['layers']?['registry'] |
fsi_registrylayerstatus |
Choice | Registry-correlation coverage status — Step 8a |
body('Parse_Summary')?['summary']?['registryCorrelation']?['registryRowCount'] |
fsi_registryrowcount |
Integer (nullable) | Registry export rows read (null when --registry-export not supplied) |
body('Parse_Summary')?['summary']?['registryCorrelation']?['matched'] |
fsi_registrymatchedcount |
Integer (nullable) | Registry rows matched to agents |
body('Parse_Summary')?['summary']?['registryCorrelation']?['unmatchedRegistryRows'] |
fsi_registryunmatchedcount |
Integer (nullable) | Registry rows with no agent match |
body('Parse_Summary')?['summary']?['registryCorrelation']?['ambiguousNameSkipped'] |
fsi_registryambiguousnameskippedcount |
Integer (nullable) | Ambiguous-name rows skipped |
body('Parse_Summary')?['summary']?['registryCorrelation']?['invalidDateWarnings'] |
fsi_registryinvaliddatewarningcount |
Integer (nullable) | Invalid as-of date warnings |
body('Parse_Summary')?['summary']?['coverageScope']?['layers']?['entitlement'] |
fsi_entitlementlayerstatus |
Choice | Entitlement-resolution coverage status — Step 8a |
body('Parse_Summary')?['summary']?['entitlementResolution']?['ownersConsidered'] |
fsi_entitlementownersconsideredcount |
Integer (nullable) | Owners considered for entitlement resolution |
body('Parse_Summary')?['summary']?['entitlementResolution']?['paidCount'] |
fsi_entitlementpaidcount |
Integer (nullable) | Owners resolved to a paid Copilot entitlement |
body('Parse_Summary')?['summary']?['entitlementResolution']?['chatOnlyCount'] |
fsi_entitlementchatonlycount |
Integer (nullable) | Owners resolved to Copilot Chat only |
body('Parse_Summary')?['summary']?['entitlementResolution']?['unknownCount'] |
fsi_entitlementunknowncount |
Integer (nullable) | Owners with unknown entitlement |
string(body('Parse_Summary')?['summary']?['coverageScope']) |
fsi_coveragescopejson |
Memo | Full coverageScope JSON (audit evidence) |
string(body('Parse_Summary')?['summary']) |
fsi_summaryjson |
Memo | Full summary JSON (audit evidence) |
Null vs zero (restated where it matters most). For
fsi_packagecountandfsi_packagenewrowcount, bind the scanner value directly. When the Package API was not attempted (for exampleabsent/Deferred), the scanner emitsnulland the column must staynull— a deferred layer is not a zero catalog. Only an attempted Package API that returned an empty catalog writes0.summary.packageNewRowCount/summary.packageScanTruncatedremain deprecated top-level mirrors, populated only when the Package API is attempted; prefer thesummary.agent365.*fields.
- Configure Run after:
Validate_Agent_Persistence— set to Succeeded. - Rename the GUID update action:
Write_Scan_Run.
Do not paste expression text into a plain field. In the new designer, open the Expression editor and enter the formula without a leading
@. Confirm the field renders one expression token such asbody(...),outputs(...), orlength(...), not visible@body(...)text. Flow Checker can report zero errors for literal text that later fails Dataverse type conversion. If the editor rejects a chained nested path, the equivalent single-path formoutputs('Parse_Summary')?['body/summary/environmentEnumeration/dataverseEnvironmentCount']is valid.
Step 8a — Label-to-Integer Conversion for fsi_caiscanrun Choice Fields¶
fsi_caiscanrun introduces Choice columns in v0.4. As in Step 7a, the scanner
emits label strings and Dataverse requires option-set integers, so add one
fail-visible Compose per Choice column below. fsi_agent365resolutionsource is
not in this list — it is a plain String column written directly from
summary.agent365.resolutionSource (one of CLI / Environment /
DeprecatedAlias / Default / LicenseProbe / DryRun) with no conversion.
Complete these mappings before the Step 8 lookup/create sequence. Use the same
Step 7a Compose pattern: json('null') for an empty nullable source, the listed
integer for a known label, and -1 for an unknown non-empty label. fsi_status
is required, so its Compose returns -1 for null or any value outside its four
listed labels. Name that Compose Map_RunStatus because the create, update, and
read-back actions reuse its output. Terminate before List rows if any mapping
Compose returns -1.
Option-set integers (locked in the v0.4 schema). The values below are the canonical option-set integers for the
fsi_caiscanrunChoice columns — map each known label to the exact integer shown. The shared layer-status option set applies tofsi_agent365layerstatus,fsi_arglayerstatus,fsi_packageapilayerstatus,fsi_registrylayerstatus,fsi_entitlementlayerstatus, andfsi_dataverselayerstatus.fsi_environmentenumerationstatusalso stores a layer-status integer but maps from a different source vocabulary (see its dedicated table below).
fsi_status — overall run status:
| Label | Integer |
|---|---|
Complete |
100000000 |
Incomplete |
100000001 |
Failed |
100000002 |
Dry Run |
100000003 |
fsi_agent365requestedmode — the scanner emits title-case (Present /
Absent / Auto); the --agent365 CLI flag itself stays lower-case:
| Scanner label | Integer |
|---|---|
Present |
100000000 |
Absent |
100000001 |
Auto |
100000002 |
fsi_agent365resolvedstate:
| Label | Integer |
|---|---|
Present |
100000000 |
Absent |
100000001 |
NotDetected |
100000002 |
Inconclusive |
100000003 |
fsi_agent365detectionconfidence:
| Label | Integer |
|---|---|
OperatorDeclared |
100000000 |
Confirmed |
100000001 |
Heuristic |
100000002 |
Inconclusive |
100000003 |
NotApplicable |
100000004 |
fsi_agent365layerstatus, fsi_arglayerstatus,
fsi_packageapilayerstatus, fsi_registrylayerstatus,
fsi_entitlementlayerstatus, and fsi_dataverselayerstatus — shared
layer-status option set (source labels come from summary.agent365.layerStatus
and summary.coverageScope.layers.*):
| Label | Integer |
|---|---|
Full |
100000000 |
Deferred |
100000001 |
Unsupported |
100000002 |
Partial |
100000003 |
Failed |
100000004 |
Dry Run |
100000005 |
fsi_environmentenumerationstatus — special mapping. Its source
(summary.environmentEnumeration.status) uses Success / Failed / Dry Run,
not the layer-status vocabulary. Map the three source labels into the shared
layer-status integers:
Source label (environmentEnumeration.status) |
Stored layer-status | Integer |
|---|---|---|
Success |
Full |
100000000 |
Failed |
Failed |
100000004 |
Dry Run |
Dry Run |
100000005 |
Fail visibly. If any run-level mapping Compose returns
-1, append the column and offending label toUnmappedRows, send a sanitized notification, and terminate with status Failed before creating the run row. Extend the mapping using dataverse-schema.md before re-running.
Step 8b — Read-back and idempotency verification¶
After Write_Scan_Run, confirm the single ledger row persisted:
- Add action: Dataverse > Get a row by ID, table
fsi_caiscanrun, usingvariables('ScanRunRowId')as the primary Row ID. - Add a Condition that all critical fields equal the parsed payload:
fsi_runidequalssummary.runId;fsi_statusequalsoutputs('Map_RunStatus');fsi_coreagentcountequalssummary.coreAgentCount;fsi_dataverseenvironmentcountequalssummary.environmentEnumeration.dataverseEnvironmentCount;fsi_dataversescannedagentcountequalssummary.scannedAgentCount;fsi_environmentfailurecountequals the length ofsummary.environmentFailures;fsi_nodataverseenvironmentcountequalssummary.environmentEnumeration.skippedNoDataverseCount;fsi_packageapiattemptedequalssummary.agent365.packageApiAttempted;fsi_packagecountequalssummary.agent365.packagesObserved, preserving null versus zero. If any comparison fails, use the false branch to send a sanitized verification-failure notification and then Terminate the flow as Failed. A technical Get-row failure is caught by the Scope failure path in Step 9.- Rename actions:
ReadBack_Scan_RunandVerify_Scan_Run.
Exactly one row per run. The lookup/create/GUID-update sequence reuses the same row for a repeated
runId, while the activefsi_ScanRunKeyuniqueness constraint blocks duplicate ledger rows. Configure the trigger for one active run because lookup and create are separate connector calls. A distinct, collision-resistantrunIdper scheduled run supports a separate ledger row for each run while agent rows join back viafsi_runid.
Step 9: Notify on Persistence Failures and Coverage Gaps¶
The persistence-failure path is required. The normal coverage-gap notification is optional.
- Add a Scope named
Persistence_Failure_NotificationafterPersist_And_Verify_Scan_Run. Configure Run after for Failed, Timed out, and Skipped. Send a sanitized persistence-failure notification from this Scope using action names/statuses rather than raw connector response bodies, then terminate the flow as Failed. - Add the normal coverage Condition after
Persist_And_Verify_Scan_Run, configured for Succeeded only. - Condition — fire when the scan is not cleanly complete, a requested layer failed, or a reconciliation gap exists:
body('Parse_Summary')?['summary']?['status']is not equal toComplete- or
length(body('Parse_Summary')?['summary']?['environmentFailures'])is greater than0 - or
body('Parse_Summary')?['summary']?['agent365']?['layerStatus']is equal toPartial - or
body('Parse_Summary')?['summary']?['agent365']?['layerStatus']is equal toFailed - or
body('Parse_Summary')?['summary']?['agent365']?['layerStatus']is equal toUnsupported - or
body('Parse_Summary')?['summary']?['agent365']?['resolvedState']is equal toInconclusive - or
length(body('Parse_Summary')?['summary']?['reconciliation']?['in_arg_only'])is greater than0 - or
length(body('Parse_Summary')?['summary']?['reconciliation']?['in_dataverse_only'])is greater than0. - In the Yes branch, add Microsoft Teams > Post adaptive card in a
chat or channel (or Send an email) summarizing the gap. Include the
overall
summary.status,summary.environmentEnumeration.status, thesummary.environmentFailures[]records (environment id, stage, HTTP status), thesummary.agent365block (resolvedState,layerStatus, sanitizederrorCode/reason), and the reconciliation deltas (agents in ARG but not scanned, and vice versa). Coverage gaps are surfaced for review, not silently dropped — anIncompleteorFailedstatus, any environment failure, or aPartial/Failed/Unsupportedrequested layer means the inventory is a partial picture and must not be treated as a clean, agent-free result. - Rename:
Check_Coverage_And_Reconciliation_Gap.
Deferred / NotDetected are informational, not alerts. Do not raise a failure notification when
summary.agent365.layerStatusisDeferredor whenresolvedStateis a heuristicNotDetected— these are expected outcomes of the operator's declared scope (for example the defaultabsentmode). They do not degradesummary.status. Report them as informational context only, and never render aDeferredLayer 4 as "zero Agent Builder agents" — those agents may still be present even when authoring-surface classification is unavailable.Unsupportedis different: it is an attempted layer the platform could not satisfy (a coverage failure), so it degrades the run and alerts exactly likePartial/Failed. Alert onPartial/Failed/Unsupportedrequested-layer outcomes, anInconclusiveresolution, or an overallIncomplete/Failedrun.Enumeration failure is a run failure. When
summary.environmentEnumeration.statusisFailed, the environment list itself could not be read (for example a 401/403). The scanner also exits non-zero in this case, so the Azure Automation job reports Failed andScope_Catch(Step 10) additionally fires. Do not interpret a zero-environment / zero-agent result from a failed enumeration as an empty tenant.
Step 10: Error Handling (Scope_Catch)¶
- Wrap steps 3–9 in a Scope named
Scope_Main. - Add a parallel Scope named
Scope_Catch. - Configure
Scope_Catchto run afterScope_Mainhas Failed or Timed Out. - Inside
Scope_Catch, add Send an email (V2) to the governance team with the error details fromScope_Main.
Troubleshooting¶
Azure Automation Job Failures¶
- Job stuck in "Running": check the Automation job logs; the job may be waiting on a module or Python package install.
- Job completes but summary is empty: verify the scanner ran with valid
credentials (
--auth-mode managed-identity) and that the managed identity has the required scopes. - Authentication errors: confirm the managed identity has consent for the Power Platform API, BAP, and per-environment Dataverse scopes.
ARG Returns No Agents (Layer 1)¶
- Confirm the type resolves:
az graph query -q "PowerPlatformResources | where type == 'microsoft.copilotstudio/agents'". - Remember the data lives in
PowerPlatformResources, not the standardresourcestable — queryingresourcesreturns nothing for this type. - If conditional access enforces ARM MFA, allow the PPAC client ID
00b46ad5-e4ae-43ac-a878-281fc03d0839and "Microsoft Azure Management".
Dataverse Write Failures¶
The flow-writer identity (the flow's Dataverse connection) is the only identity that writes the CAI tables; the read-only scanner never writes Dataverse.
| Error Code | Cause | Resolution |
|---|---|---|
| 403 Forbidden | Flow-writer identity lacks Create/Write on CAI tables | Assign the flow's Dataverse connection a role with Organization-level Create on the CAI tables in the governance environment |
| 404 Not Found | Table not deployed | Deploy the schema (scripts/create_cai_dataverse_schema.py) |
| 400 Bad Request | Column-name mismatch | Verify logical column names against dataverse-schema.md |
Scan Reports Incomplete / Failed or Environment Failures¶
The scanner now surfaces coverage gaps instead of returning a success-shaped empty result. Use the summary signals to diagnose:
summary.environmentEnumeration.status == "Failed"— the environment list itself could not be read (often 401/403). Confirm the scanner service principal is registered as a Power Platform management application and has ARM access. Zero environments here is a failure, not an empty tenant.- An environment in
summary.environmentFailures[]withstage: "bots"andhttpStatus: 403— the scanner service principal is missing (or lacks the read-only role) as an application user in that environment. Add the read-only application user forbot/botcomponentand re-run. This is the coverage stop condition from prerequisites.md. stage: "botcomponents"failures — the bot was discovered but its feature read failed; the agent is retained and flaggedIncomplete Scan.summary.argLayer.status == "Failed"— a Layer 1 ARG query failure (distinct fromAvailablewithagentCount: 0). Layer 2 remains the load-bearing default; reconciliation is degraded until ARG succeeds.
botcomponent Query Returns 400 (Layer 2)¶
- The
botcomponent→botlookup isparentbotid(_parentbotid_value), not_botid_value. Filtering on_botid_valuereturns400 Bad Request.
Copilot Agent Inventory — Flow Setup Guide v0.4.0-preview