PowerShell Setup — Control 2.27: Consumption-Entitlement Governance
Scope. Operational PowerShell and Microsoft Graph automation for the eight Key Configuration Points of Control 2.27: confirm licensing and billing prerequisites, inventory the two consumption policy objects, register and validate the admission-gated security-group registry, classify each agent's consumption pathway, evaluate the switch-on-pathway entitlement contract across the
(agent, user)population, record per-agent metered spend caps, run the pre-enforcement coverage-gap analysis monitor-only, and persist and forward entitlement and coverage-gap evidence. The automation is implemented by the companion Copilot Billing Governance (CBG) solution (copilot-billing-governance/).What this control governs. Control 2.27 governs the entitlement decision — which users are entitled to incur metered or premium Copilot consumption on an agent, under which billing or credit policy, on which surface. It supports compliance with the recordkeeping and IT-general-control expectations referenced below; it does not, on its own, satisfy any regulation, and organizations should verify their configuration meets their specific obligations.
Read-and-analyze first, enforce later. Every helper in this playbook is read-only or analysis-only. No helper mutates a Microsoft billing/credit policy, writes a cap hard-stop, or activates enforcement. The pre-enforcement coverage-gap analysis (§9) is the gate: its would-be-blocked population is reviewed and signed off before any enforcement is contemplated.
Authentication is managed-identity-first. See §0. Run inventory and evaluation under a managed identity holding the least-privilege Graph and Dataverse scopes; prefer the AI Administrator role over Entra Global Admin for day-to-day operation, and use Entra Privileged Identity Management (PIM) for just-in-time elevation where a one-time tenant action is unavoidable.
Upstream dependency. The agent dimension (
createdIn) and usage tier (configuredTier) are produced by the sibling solutionscopilot-agent-inventory(Azure Resource GraphPowerPlatformResources) andwork-iq-usage-detection. Until those are catalog-registered, the entitlement engine operates on an assembled/fixture input set via-InputPath. Work IQ general availability and the move of the Work IQ API to Copilot Credits consumption billing are scheduled for June 16, 2026 — per the Microsoft 365 roadmap (feature 559017) and Microsoft Learn ("use-work-iq"), verified June 2026 (a near-term rollout).Write-API posture (verified June 2026). There is no public write API to set an enforceable credit cap or hard spend-stop: per-agent caps and Copilot credit policies are Power Platform admin-center UI features (Licensing > Copilot Studio > Manage Agents) and the Billing Policy CRUD API exposes no credit-cap field (per Microsoft Learn, "manage-copilot-studio-messages-capacity"). Where a hard-stop is unavailable, enforcement degrades to detect-and-alert — the cap is recorded and breaches are surfaced; it is not represented as a hard-stop. Should Microsoft ship a public cap-enforcement API, caps can be upgraded to a hard-stop. The live credit-policy read API is likewise unproven and falls back to the reconciled Dataverse store.
0. Prerequisites, authentication, and false-clean defects
0.1 Authentication and least privilege
Prerequisites and least-privilege authentication
Configuring and evaluating metered-consumption entitlement requires Microsoft 365 Copilot licensing for in-scope users, at least one Microsoft Copilot consumption-billing policy (pay-as-you-go and/or prepaid credit), and an operating identity that can read Copilot license assignment, Entra group properties, and the CBG Dataverse tables.
- Managed-identity-first. Acquire tokens from a system-assigned managed identity (Azure Automation, Function, container host), a user-assigned managed identity for shared automation, or workload identity federation (GitHub Actions OIDC → Entra app) for CI. Use interactive / device-code only for one-off admin-workstation runs. A client secret is a legacy development-only fallback — rotate and remove it before production; do not prescribe it as the recommended path.
- Role preference. Prefer the AI Administrator role for operating Copilot billing/credit policies and Entra scope-group assignment. Reserve Entra Global Admin for one-time tenant setup, and activate it through PIM for just-in-time, time-bound elevation. This keeps the day-to-day operating principal least-privileged, which is recommended to support the SOX 404 ITGC narrative around who can authorize spend.
- Graph scopes (read-only):
User.Read.All(read Copilot license assignment),Group.Read.All(readsecurityEnabled/mailEnabled/groupTypesat admission), andOrganization.Read.All(read subscribed SKUs). No write scope is required by any helper in this playbook.
#Requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Local checkout path of the Copilot Billing Governance solution scripts (adjust to your machine).
$cbgScripts = 'C:\src\fsi-agentgov-solutions\copilot-billing-governance\scripts'
function Assert-Agt227Shell {
[CmdletBinding()]
param()
if ($PSVersionTable.PSEdition -ne 'Core') {
throw "Control 2.27 helpers require PowerShell 7.4 Core. Detected edition: $($PSVersionTable.PSEdition). Re-launch under pwsh.exe."
}
if ($PSVersionTable.PSVersion -lt [version]'7.4.0') {
throw "Control 2.27 helpers require PowerShell 7.4 LTS or later. Detected: $($PSVersionTable.PSVersion). Upgrade before continuing."
}
$loadedDesktopGraph = Get-Module -Name 'Microsoft.Graph*' |
Where-Object { $_.Path -match 'WindowsPowerShell\\v1\.0' }
if ($loadedDesktopGraph) {
throw "Detected Microsoft.Graph modules loaded from the Windows PowerShell 5.1 path. This shell is contaminated; start a clean pwsh 7.4 process."
}
Write-Verbose "Shell parity confirmed: PowerShell $($PSVersionTable.PSVersion) Core."
}
Assert-Agt227Shell -Verbose
0.2 False-clean defects to refuse
Empty output is the most dangerous signal in a consumption-entitlement control: a tenant with no metered agents, an unlicensed operating principal, or an empty configuredTier all produce quiet results that look identical to "clean." Each helper in §§3–10 returns a structured object with a Status field whose values are Clean, Anomaly, Pending, NotApplicable, or Error. No helper returns $null or an empty array as a clean signal. When there is genuinely no data, the helper returns a single object with Status='NotApplicable' and a populated Reason.
| # | Defect | Symptom | Why it appears clean | Structural guard |
|---|---|---|---|---|
| 0.1 | No Copilot licensing in tenant | License read returns zero Copilot SKUs | No exception is thrown; the entitlement input has no licensed users, so every mcp-cs / mcp-agentbuilder evaluation blocks on "Missing license" and the result looks decisive |
§3 Test-Agt227Prerequisite checks Get-MgSubscribedSku for a Copilot SKU and returns Status='NotApplicable' with a reason rather than an empty allow-set |
| 0.2 | Wrong shell edition | Graph cmdlets succeed but return @() |
Microsoft.Graph 2.x assemblies fail to bind under Desktop edition; the SDK swallows the bind failure and returns empty | Assert-Agt227Shell (§0.1) blocks Desktop edition entirely |
| 0.3 | Empty configuredTier silently classed as none |
Many agents resolve to none → Allow (eligibility N/A) |
When the upstream Work IQ feed is missing, configuredTier is empty and the engine's createdIn fallback may still land on none; the agent majority looks entitled when it is actually unclassified |
§6 flags agents whose configuredTier and createdIn are both empty as unmapped candidates and counts them separately; a high unmapped/empty rate is treated as a feed defect, not a clean pass |
| 0.4 | Mail-enabled group accepted into the registry | A distribution or Microsoft 365 group is used as a credit-scope / audience group and evaluates as valid | Membership reads succeed against a mail-enabled group, so eligibility "works" while violating the admission gate | §5 Test-Agt227GroupAdmission rejects any group that is not securityEnabled or that is mailEnabled, emitting Admitted=$false |
| 0.5 | Detect-and-alert mistaken for a hard-stop | A per-agent cap row exists, so spend "looks capped" | No public write API enforces the cap; the row records intent only | §8 stamps EnforcementMode='Detect-and-alert' and EnforcementIsHardStop=$false whenever the write API is unconfirmed; the cap is never reported as a hard-stop |
| 0.6 | Zero-rating default hides fail-closed cases | mcp-cs users broadly resolve to Allow |
-ZeroRatingResolved defaults $true per the June 2026 Licensing Guide (footnotes 6 & 7, verified June 2026); a licensed user on a zero-rated M365 surface is allowed, which can mask users who would fail-closed under the conservative posture |
§7 records the ZeroRatingResolved posture per run and supports a paired -ZeroRatingResolved:$false shadow run so the conservative fail-closed population is visible before enforcement |
| 0.7 | Coverage-gap run as if it were enforcement | A gap export exists but enforcement assumptions are baked in | An operator runs the analysis after toggling enforcement, so monitorOnly is false and the would-be-blocked population is understated |
§9 asserts fsi_monitoronly = true on every pre-enforcement row and refuses to treat any export with a non-monitor row as a valid pre-enforcement sign-off artifact |
| 0.8 | Credit-policy "live read" assumed authoritative | -FromPlatform returns PAYG rows but no credit rows |
The credit-policy admin API is unproven; the live read falls back to Dataverse and may under-report | §3 surfaces Source='platform-with-dataverse-fallback' and warns that credit rows came from the reconciled store, not a live platform read |
| 0.9 | Native retention assumed sufficient | Entra/Graph audit query for an old decision finds nothing | Directory audit retention is far shorter than the FINRA 4511 six-year horizon; the SIEM is the system of record | §10 emits an explicit retention horizon and a SIEM-forwarding pointer; the materialized decision store carries fsi_retainuntil |
1. Modules, permissions, and canonical roles
1.1 Module pinning
| Module | Minimum version | Pinned for | Notes |
|---|---|---|---|
Microsoft.Graph.Authentication |
2.19.0 | Token acquisition, scope validation | Loads transitively; pin explicitly to lock the cmdlet surface |
Microsoft.Graph.Users |
2.19.0 | Get-MgUserLicenseDetail (per-user Copilot license read) |
Entitlement input for the license-gated pathways |
Microsoft.Graph.Groups |
2.19.0 | Get-MgGroup (registry admission: securityEnabled / mailEnabled / groupTypes) |
Backs §5 admission gating |
Microsoft.Graph.Identity.DirectoryManagement |
2.19.0 | Get-MgSubscribedSku (Copilot SKU presence) |
Backs §3 licensing preflight |
Az.Accounts |
2.15.0 | Managed-identity / Get-AzAccessToken for Dataverse and the Power Platform billing-policy admin API (BAP) |
Token source only; no Az resource mutation |
function Install-Agt227ModuleBaseline {
[CmdletBinding(SupportsShouldProcess)]
param()
$required = @(
@{ Name = 'Microsoft.Graph.Authentication'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Users'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Groups'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; RequiredVersion = '2.19.0' }
@{ Name = 'Az.Accounts'; RequiredVersion = '2.15.0' }
)
foreach ($mod in $required) {
$installed = Get-Module -ListAvailable -Name $mod.Name |
Where-Object { $_.Version -eq [version]$mod.RequiredVersion }
if (-not $installed) {
if ($PSCmdlet.ShouldProcess($mod.Name, "Install $($mod.RequiredVersion)")) {
Install-Module -Name $mod.Name -RequiredVersion $mod.RequiredVersion `
-Scope CurrentUser -Repository PSGallery -Force -AllowClobber
}
}
Import-Module -Name $mod.Name -RequiredVersion $mod.RequiredVersion -Force -ErrorAction Stop
}
[pscustomobject]@{
ModulesPinned = $required.Count
Status = 'Clean'
Timestamp = (Get-Date).ToUniversalTime()
}
}
1.2 Permission matrix (Graph, Dataverse, and the billing-policy admin API)
The read surface spans three resources: Microsoft Graph (license + group reads), the CBG Dataverse environment (reconciled policy / entitlement / coverage-gap rows), and — only for -FromPlatform PAYG reads — the Power Platform billing-policy admin API (BAP).
| Operation | Resource / scope | Type | Helpers |
|---|---|---|---|
| Read per-user Copilot license assignment | Graph User.Read.All |
Application or delegated | §3, §6 |
| Read Entra group admission properties | Graph Group.Read.All |
Application or delegated | §5 |
| Read subscribed SKUs (Copilot presence) | Graph Organization.Read.All |
Application or delegated | §3 |
| Read CBG reconciled rows | Dataverse Web API on the environment URL (e.g. https://contoso.crm.dynamics.com) |
Dataverse app-user (least-privilege read role) | §3, §6, §8, §9, §10 |
Read PAYG billing policies live (-FromPlatform) |
Power Platform BAP https://api.bap.microsoft.com/ |
Managed identity / workload identity | §3 |
| Persist materialized decisions / coverage-gap rows | Dataverse Web API (PATCH on alternate key) |
Dataverse app-user (write role) | §10 |
Write-API boundary. There is no Microsoft billing/credit policy write scope in this matrix. Creating a PAYG policy, a credit policy, or a cap hard-stop is performed through the documented portal flows in §4 and §8, not through these helpers. The only writes any helper performs are to the CBG Dataverse evidence tables (§10) — never to a Microsoft consumption policy.
1.3 Canonical roles
Use the canonical doc-body role names. Mixing legacy long-form names across scripts and runbooks creates audit-trail friction.
| Canonical role | When required | Scope |
|---|---|---|
| AI Governance Lead | Owns the control; convenes the §9 coverage-gap review; approves enforcement activation | Tenant (governance) |
| AI Administrator | Operates §3–§9 (policy inventory, registry assignment, evaluation, caps, coverage-gap); Copilot usage exports. Preferred over Entra Global Admin | Tenant (privileged; activate via PIM) |
| Entra Global Admin | One-time tenant setup of Copilot billing policies only; subsequent operation delegates to AI Administrator under PIM | Tenant |
| Power Platform Admin | Dataverse schema (entitlement / cap / coverage-gap tables) and the evaluation flows | Tenant / environment |
| Entra User Admin | Maintains the admission-gated security-group registry (credit-scope, API-audience, billing groups) | Tenant |
| Finance / Controller | Approves cap thresholds and spend appetite; signs the cost-estimate basis; maintains SOX 404 ITGC documentation | Governance |
| Compliance Officer | Confirms retention per FINRA 4511 / SEC 17a-4(b)(4); produces examination evidence | Governance |
| Business Unit Owner | Approves entitled cohorts for their unit's agents; responds to coverage-gap findings | Business unit |
PIM discipline. Operate from a session whose privileged role activation is fresh. The §2 bootstrap stamps the operating role into session metadata so evidence records the principal under which an evaluation ran.
2. Session initialization
The bootstrap connects to Microsoft Graph (US commercial cloud), resolves a Dataverse token managed-identity-first, and records the operating posture. It does not connect to any billing/credit policy write surface.
function Initialize-Agt227Session {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$EnvironmentUrl, # CBG Dataverse environment, e.g. https://contoso.crm.dynamics.com
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$ClientId, # user-assigned managed identity / app registration
[Parameter()]
[switch]$UseDeviceCode, # one-off admin-workstation runs only
[Parameter()]
[string]$DataverseAccessToken # managed-identity-supplied; omit to fall back (dev-only)
)
Assert-Agt227Shell
$scopes = @('User.Read.All','Group.Read.All','Organization.Read.All')
$connectArgs = @{ Scopes = $scopes; NoWelcome = $true; ContextScope = 'Process' }
if ($TenantId) { $connectArgs.TenantId = $TenantId }
if ($ClientId) { $connectArgs.ClientId = $ClientId }
if ($UseDeviceCode) { $connectArgs.UseDeviceCode = $true }
Connect-MgGraph @connectArgs | Out-Null
$context = Get-MgContext
$missing = $scopes | Where-Object { $_ -notin $context.Scopes }
if ($missing) {
throw "Token missing required read scopes: $($missing -join ', '). Re-consent required."
}
# Dataverse token, managed-identity-first. Resolve-Agt227DataverseToken is defined in §3.1.
$dvToken = Resolve-Agt227DataverseToken -ResourceUrl $EnvironmentUrl -ProvidedToken $DataverseAccessToken
$session = [pscustomobject]@{
ControlId = '2.27'
Cloud = 'Commercial'
GraphEndpoint = 'https://graph.microsoft.com'
EnvironmentUrl = $EnvironmentUrl.TrimEnd('/')
TenantId = $context.TenantId
OperatingScopes = $scopes
DataverseToken = $dvToken
SessionStarted = (Get-Date).ToUniversalTime()
Status = 'Clean'
}
Set-Variable -Name 'Agt227Session' -Value $session -Scope Script -Force
return $session
}
Token hygiene. The session object carries a live Dataverse bearer token. Treat the variable as sensitive: do not serialize
$Agt227Sessioninto evidence exports, and clear it (Remove-Variable Agt227Session -Scope Script) at the end of an operating run.
3. Key Configuration Point 1 — Confirm licensing and billing prerequisites
Verify that in-scope users hold a Microsoft 365 Copilot license, that at least one consumption-billing policy object exists, and that the operating principal holds AI Administrator (preferred over Entra Global Admin; PIM for JIT). The policy inventory itself is read by the CBG Get-BillingPolicyInventory.ps1 script (§4); this preflight establishes that evaluation is meaningful before any pathway is classified.
3.1 Dataverse token resolution (managed-identity-first)
This mirrors the CBG Get-BillingPolicyInventory.ps1 token helper exactly: a supplied token is used as-is; otherwise it falls back to Get-AzAccessToken for development only.
function Resolve-Agt227DataverseToken {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ResourceUrl,
[string]$ProvidedToken
)
if (-not [string]::IsNullOrWhiteSpace($ProvidedToken)) {
return $ProvidedToken
}
# legacy: dev-only — replace with a managed-identity-supplied -DataverseAccessToken in production
Write-Verbose 'No Dataverse token supplied; falling back to Get-AzAccessToken (dev-only).'
if (-not (Get-Command -Name Get-AzAccessToken -ErrorAction SilentlyContinue)) {
throw "No token provided and Az.Accounts (Get-AzAccessToken) is unavailable. Supply a managed-identity token, or install Az.Accounts and sign in."
}
$secure = (Get-AzAccessToken -ResourceUrl $ResourceUrl -AsSecureString).Token
return ($secure | ConvertFrom-SecureString -AsPlainText)
}
3.2 Licensing preflight
function Test-Agt227Prerequisite {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
# Copilot SKU part numbers vary by tenant; verify in your tenant with Get-MgSubscribedSku.
[string[]]$CopilotSkuPattern = @('Microsoft_365_Copilot','Copilot')
)
$now = (Get-Date).ToUniversalTime()
$skus = Get-MgSubscribedSku -All -ErrorAction Stop
$copilotSku = $skus | Where-Object {
$part = $_.SkuPartNumber
($CopilotSkuPattern | Where-Object { $part -match $_ })
}
$hasCopilot = [bool]$copilotSku
$enabledUnits = if ($hasCopilot) { ( $copilotSku | ForEach-Object { $_.PrepaidUnits.Enabled } | Measure-Object -Sum ).Sum } else { 0 }
$status = if ($hasCopilot) { 'Clean' } else { 'NotApplicable' }
$reason = if ($hasCopilot) {
$null
} else {
'No Microsoft 365 Copilot SKU found in the tenant. Entitlement evaluation is not meaningful without licensed users; verify the SKU part number pattern against Get-MgSubscribedSku.'
}
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'LicensingAndBillingPrerequisite'
HasCopilotSku = $hasCopilot
CopilotSkuParts = @($copilotSku.SkuPartNumber)
CopilotPrepaidUnits = $enabledUnits
Status = $status
Reason = $reason
Timestamp = $now
}
}
Verify. A
Status='NotApplicable'here is a stop condition for Zone 2/3 evaluation, not a clean pass — it means the entitlement input has no licensed users. Record theCopilotSkuPartsactually observed; Copilot SKU part numbers change and the regex is a starting point, not an assertion. 🔎
4. Key Configuration Point 2 — Establish the two policy objects and choose a configuration
Two policy objects back metered spend, and three configurations compose them:
PAYG billing policy (fsi_cbgbillingpolicy) |
Prepaid credit policy (fsi_cbgcreditpolicy) |
|
|---|---|---|
| Backing | Azure subscription | Prepaid (no Azure subscription) |
| Tenant ceiling | 50 | 10 |
| Setup | Two-step add → connect | Standalone |
| Spend control | Budget alerts only — not a hard-stop | Standalone hard-stop |
| Surfaces | All metered surfaces including SharePoint grounding | Chat-only today; SharePoint stays on PAYG |
Three supported configurations: credit-only, credit + PAYG, PAYG-only.
Tenant ceilings (confirmed as of June 2026). Up to 50 PAYG billing policies per tenant (Microsoft Learn — pay-as-you-go) and up to 10 Copilot credit policies per tenant (Microsoft Learn — requirements-messages-management). Pricing is time-sensitive; re-confirm against current Microsoft licensing documentation as it changes.
4.1 Creating the policies (documented portal flows)
There is no confirmed public PowerShell cmdlet that creates a Copilot PAYG billing policy or a prepaid credit policy. Do not substitute the SharePoint New-SPOAppBillingPolicy cmdlet — it governs SharePoint add-in billing, not Copilot consumption. Create the policies through the documented portal flows:
- PAYG billing policy (two-step add → connect). In the Power Platform admin center → Licensing → Pay-as-you-go plans → New billing plan, choose Azure subscription (general metered surfaces) or Microsoft 365 Copilot Chat (the Copilot consumption meter), name the plan, select the Azure subscription and resource group (add), then select the environments to link (connect). PAYG provides budget alerts only — not a hard-stop. Reference: Set up pay-as-you-go and pay-as-you-go overview. (The current portal labels these "billing plans"; the API and the CBG schema use "billing policy" — they are the same object.)
- Prepaid credit policy. Configure the Copilot credit policy in the Microsoft 365 admin center Copilot management surface. Credit policies are a standalone hard-stop and are Chat-only today 🔎; SharePoint-grounded consumption stays on PAYG.
Write-API reality (verified June 2026). A public Billing Policy CRUD API does exist:
POST/PUT/DELETE https://api.powerplatform.com/licensing/billingPolicies?api-version=2024-10-01, plus Add / Remove Billing Policy Environment — so a PAYG billing policy can be created and connected programmatically. That API exposes no credit-cap or spend-ceiling field, however (its writable fields are name, statusEnabled/Disabled, and the Azure billing instrument only), and PAYG budgets are alert-only — Microsoft states the system doesn't enforce the budget or stop the organization from exceeding it. Copilot credit policies (≤10 per tenant) and per-agent monthly credit caps are Microsoft 365 / Power Platform admin-center UI features (Licensing > Copilot Studio > Manage Agents) with no public write API, per Microsoft Learn — manage Copilot Studio message capacity. Net: there is no public API to set an enforceable credit cap or hard spend-stop, so cap enforcement correctly degrades to detect-and-alert. Should Microsoft ship a cap/credit-policy write surface, validate it in a non-production tenant before automating.
4.2 Inventory the policies and the ceilings (read-only)
The CBG Get-BillingPolicyInventory.ps1 script reads both objects and reports headroom against the 50 / 10 ceilings. The PAYG live read uses the documented BAP endpoint; the credit live read is unproven and falls back to the reconciled Dataverse store.
# Proven path — read the reconciled Dataverse rows the CBG-PolicySync flow maintains:
$inventory = & "$cbgScripts\Get-BillingPolicyInventory.ps1" `
-EnvironmentUrl $Agt227Session.EnvironmentUrl `
-AccessToken $Agt227Session.DataverseToken
# Optional live PAYG read (credit policies still fall back to Dataverse):
$inventory = & "$cbgScripts\Get-BillingPolicyInventory.ps1" `
-EnvironmentUrl $Agt227Session.EnvironmentUrl `
-AccessToken $Agt227Session.DataverseToken `
-FromPlatform `
-BillingApiAccessToken $bapToken # resource https://api.bap.microsoft.com/
$inventory.PayAsYouGo | Format-List Count, Ceiling, Headroom, AtCeiling
$inventory.Credit | Format-List Count, Ceiling, Headroom, AtCeiling
$inventory.Source # 'dataverse' or 'platform-with-dataverse-fallback'
The documented BAP read the script performs (real, from the CBG source) is:
GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/billingPolicies?api-version=2022-03-01-preview
Authorization: Bearer <BAP token>
Verify. Record the chosen configuration (credit-only / credit + PAYG / PAYG-only), the observed
CountvsCeilingfor each object, and theSource. ASource='platform-with-dataverse-fallback'means the credit rows came from the reconciled store, not a live platform read (defect 0.8) — note it in the configuration record. The 50 / 10 ceilings are confirmed as of June 2026 (Microsoft Learn — pay-as-you-go / requirements-messages-management); re-confirm against current Microsoft licensing documentation as pricing changes.
5. Key Configuration Point 3 — Register the admission-gated security-group registry
Entitlement scope (credit-scope, API-audience, and billing groups) is expressed through Entra security groups held in the admission-gated registry fsi_cbgapprovedgrouppolicy. Every registered group must be securityEnabled and not mailEnabled — a mail-enabled distribution or Microsoft 365 group is rejected at admission. This is the source of the "who is in credit scope / eligible cohort" checks the engine relies on.
5.1 Admission gate
function Test-Agt227GroupAdmission {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)][string]$GroupId,
# fsi_cbg_grouplayer: Maker | Audience | Billing
[Parameter(Mandatory)][ValidateSet('Maker','Audience','Billing')][string]$GroupLayer
)
$g = Get-MgGroup -GroupId $GroupId -Property 'id,displayName,securityEnabled,mailEnabled,groupTypes' -ErrorAction Stop
$admitted = ($g.SecurityEnabled -eq $true) -and ($g.MailEnabled -ne $true)
$rejectReason = @()
if ($g.SecurityEnabled -ne $true) { $rejectReason += 'not securityEnabled' }
if ($g.MailEnabled -eq $true) { $rejectReason += 'mailEnabled (distribution / M365 group rejected)' }
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'GroupRegistryAdmission' # manifest check 2.27.d
GroupId = $g.Id
GroupDisplayName = $g.DisplayName
GroupLayer = $GroupLayer
SecurityEnabled = [bool]$g.SecurityEnabled
MailEnabled = [bool]$g.MailEnabled
GroupTypes = ($g.GroupTypes -join ',')
Admitted = $admitted
Status = if ($admitted) { 'Clean' } else { 'Anomaly' }
Reason = if ($admitted) { $null } else { "Rejected: $($rejectReason -join '; ')" }
Timestamp = (Get-Date).ToUniversalTime()
}
}
The admitted row maps to fsi_cbgapprovedgrouppolicy with these logical names: fsi_groupid, fsi_grouplayer (Maker = 100000000, Audience = 100000001, Billing = 100000002), fsi_securityenabled, fsi_mailenabled, fsi_grouptypes, fsi_zoneclassification, fsi_isactive, fsi_approvedby, fsi_approvedat. The alternate key is (fsi_groupid, fsi_grouplayer).
5.2 Sweep the registry and prove zero mail-enabled scope groups
function Get-Agt227RegistryAdmissionReport {
[CmdletBinding()]
param([Parameter(Mandatory)][pscustomobject[]]$Registry) # each row: GroupId, GroupLayer
$rows = foreach ($entry in $Registry) {
Test-Agt227GroupAdmission -GroupId $entry.GroupId -GroupLayer $entry.GroupLayer
}
$rejected = @($rows | Where-Object { -not $_.Admitted })
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'GroupRegistryAdmission'
GroupsChecked = $rows.Count
MailEnabledFound = @($rows | Where-Object MailEnabled).Count
RejectedCount = $rejected.Count
Rejected = $rejected
Status = if ($rejected.Count -eq 0 -and $rows.Count -gt 0) { 'Clean' }
elseif ($rows.Count -eq 0) { 'NotApplicable' }
else { 'Anomaly' }
Timestamp = (Get-Date).ToUniversalTime()
}
}
Verify (manifest check
2.27.d). The evidence for criterion 2 is this report showing zero mail-enabled scope groups and a non-emptyGroupsChecked. An empty registry returnsNotApplicable, notClean— a Zone 2/3 tenant with metered agents must have populated scope groups.
6. Key Configuration Point 4 — Classify each agent's consumption pathway
Each agent maps to exactly one fsi_cbg_pathway: none (100000000), mcp-cs (100000001), mcp-agentbuilder (100000002), api-direct (100000003), metered (100000004), unmapped (100000005). The Work IQ configuredTier is authoritative and evaluated first; the Azure Resource Graph createdIn signal is a last-resort fallback consulted only when configuredTier is empty or unrecognized. unmapped is an anomaly to investigate, not a reason to block users.
6.1 Classification, mirroring the engine's Get-AgentPathway
The runnable classifier is Get-AgentPathway inside the CBG Invoke-EntitlementEvaluation.ps1. The mapping it applies (verbatim from the engine):
configuredTier (Work IQ, lowercased) |
Pathway |
|---|---|
nativemcpcopilotstudio |
mcp-cs |
nativeapidirect |
api-direct |
notconfigured, adjacent, none, classic, non-metered, nonmetered |
none |
metered, generative, grounded, agent-action, premium |
metered |
Only when configuredTier is empty/unrecognized does the engine fall back to createdIn (regex): Copilot Studio → mcp-cs; Agent Builder → mcp-agentbuilder; api / declarative / direct-line / custom → api-direct; otherwise unmapped.
function Get-Agt227PathwayPreview {
[CmdletBinding()]
param(
[Parameter(Mandatory)][AllowNull()][AllowEmptyString()][string]$CreatedIn,
[Parameter(Mandatory)][AllowNull()][AllowEmptyString()][string]$ConfiguredTier
)
$tier = ("$ConfiguredTier").Trim().ToLowerInvariant()
$created = ("$CreatedIn").Trim().ToLowerInvariant()
# configuredTier is authoritative — evaluated FIRST.
$pathway =
if ($tier -eq 'nativemcpcopilotstudio') { 'mcp-cs' }
elseif ($tier -eq 'nativeapidirect') { 'api-direct' }
elseif ($tier -in @('notconfigured','adjacent','none','classic','non-metered','nonmetered')) { 'none' }
elseif ($tier -in @('metered','generative','grounded','agent-action','premium')) { 'metered' }
elseif ($created -match 'copilot.?studio|^cs$|mcp-cs') { 'mcp-cs' }
elseif ($created -match 'agent.?builder|mcp-agentbuilder') { 'mcp-agentbuilder' }
elseif ($created -match 'api|declarative|direct.?line|custom'){ 'api-direct' }
else { 'unmapped' }
$bothEmpty = [string]::IsNullOrWhiteSpace($tier) -and [string]::IsNullOrWhiteSpace($created)
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'PathwayClassification' # manifest checks 2.27.a / .c
ConfiguredTier = $ConfiguredTier
CreatedIn = $CreatedIn
Pathway = $pathway
FeedMissing = $bothEmpty # defect 0.3: both signals empty
Status = if ($pathway -eq 'unmapped') { 'Anomaly' } elseif ($bothEmpty) { 'Anomaly' } else { 'Clean' }
Timestamp = (Get-Date).ToUniversalTime()
}
}
Verify. This helper is a preview for triage — the authoritative classification is performed inside the engine in §7. Treat a non-zero
unmappedcount (or a non-zeroFeedMissingcount) as an upstream-feed defect to investigate, and record eachunmappedagent as an anomaly with a follow-up owner (criterion 3). A highunmapped/empty rate when the Work IQ feed is offline is the §0.2 defect 0.3 condition, not a clean pass.
7. Key Configuration Point 5 — Apply the entitlement contract per pathway
The contract is evaluated by the CBG Invoke-EntitlementEvaluation.ps1 engine. It classifies the pathway (§6), then applies pathway-specific eligibility for each intended user, emitting one decision per (agent, user) and one per-agent coverage-gap aggregate (§9). Decisions map to fsi_cbg_decision:
| Decision | fsi_cbg_decision |
When |
|---|---|---|
| Allow | 100000000 | mcp-agentbuilder/api-direct/mcp-cs/metered eligibility satisfied |
| Block | 100000001 | A bounded block inside a metered or license/cohort-gated pathway (no eligible cohort / missing license) |
| Allow - Eligibility N/A | 100000002 | none — the non-metered agent majority |
| Fail-open - Anomaly | 100000003 | unmapped — permits the user but records an anomaly (a detection defect must not deny a user) |
| Fail-closed - Zero-rating Unresolved | 100000004 | Licensed mcp-cs user, surface not zero-rated (or zero-rating reverted) and not in credit scope |
Eligibility by pathway (verbatim from the engine's Resolve-EntitlementDecision):
none→ Allow - Eligibility N/A (no metered consumption; no billing decision).mcp-agentbuilder→ requires a Copilot license, else Block (Missing license).api-direct→ requires API-audience-cohort membership, else Block (No eligible cohort).mcp-cs→ requires a Copilot license AND (the surface is zero-rated whenZeroRatingResolvedOR the user is in credit scope); otherwise Fail-closed - Zero-rating Unresolved.metered→ requires eligible-cohort membership; the only unboundedELSE, bounded to a metered pathway, else Block (No eligible cohort).unmapped→ Fail-open - Anomaly.
7.1 Input shape
Each agent record carries agentId, agentName, createdIn, configuredTier, spendScope (Chat / SharePoint / Mixed), optional sourcePolicyId, and an intendedUsers[] array. Each user carries upn, hasCopilotLicense, inApiAudienceGroup, inEligibleCohort, inCreditScopeGroup, and surfaceZeroRated. Today the engine reads this from a fixture via -InputPath (the upstream copilot-agent-inventory and work-iq-usage-detection feeds are not yet live); the boolean user attributes are assembled from the Graph license read (§3) and the registry membership reads (§5).
# Assemble one intended-user record from the Graph license read + registry membership.
function New-Agt227IntendedUser {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Upn,
[Parameter(Mandatory)][bool]$HasCopilotLicense, # from Get-MgUserLicenseDetail (Copilot service plan present)
[bool]$InApiAudienceGroup,
[bool]$InEligibleCohort,
[bool]$InCreditScopeGroup,
[bool]$SurfaceZeroRated
)
[pscustomobject]@{
upn = $Upn
hasCopilotLicense = $HasCopilotLicense
inApiAudienceGroup = [bool]$InApiAudienceGroup
inEligibleCohort = [bool]$InEligibleCohort
inCreditScopeGroup = [bool]$InCreditScopeGroup
surfaceZeroRated = [bool]$SurfaceZeroRated
}
}
A per-user Copilot license read uses the real Graph cmdlet:
$details = Get-MgUserLicenseDetail -UserId $upn
$hasCopilot = [bool]($details.ServicePlans |
Where-Object { $_.ServicePlanName -match 'COPILOT' -and $_.ProvisioningStatus -eq 'Success' }) # 🔎 confirm the service-plan name in your tenant
7.2 Run the evaluation
# Default posture: zero-rating RESOLVED per the June 2026 Licensing Guide (footnotes 6 & 7, verified June 2026).
& "$cbgScripts\Invoke-EntitlementEvaluation.ps1" `
-InputPath .\agents.input.json `
-OutputPath .\entitlement-result.json
# Conservative shadow run: revert to fail-closed so the at-risk mcp-cs population is visible.
& "$cbgScripts\Invoke-EntitlementEvaluation.ps1" `
-InputPath .\agents.input.json `
-OutputPath .\entitlement-result.failclosed.json `
-ZeroRatingResolved:$false
The engine's real parameters: -InputPath (mandatory), -OutputPath, -ZeroRatingResolved (bool, default $true), -CacheTtlMinutes (default 1440), -SampleCap (default 20), -GroupSizeThreshold (default 500), -RetentionDays (default 183). It emits a result object with Decisions[] (shaped to fsi_cbgentitlementmaterialized) and CoverageGaps[] (shaped to fsi_cbgcoveragegap). The engine is write-free — Dataverse persistence is performed by the CBG-CoverageGapAnalyzer flow (and the §10 evidence helper).
7.3 Decision field reference (fsi_cbgentitlementmaterialized)
Each emitted decision carries these logical names: fsi_name (agentid:userupn cache key), fsi_agentid, fsi_userupn, fsi_pathway, fsi_decision, fsi_decisionreason (fsi_cbg_blockreason, null for allows), fsi_spendscope, fsi_sourcepolicyid, fsi_evaluatedat, fsi_ttlexpiresat, fsi_notes (the evaluation trace). The alternate key is (fsi_agentid, fsi_userupn).
Verify (manifest check
2.27.a). Evidence for criterion 4 is the materialized decision export showing pathway, decision, and block reason, with theZeroRatingResolvedposture recorded per run. Run both the default and the-ZeroRatingResolved:$falseshadow so the fail-closed delta is visible before enforcement (defect 0.6).
Entitlement-decision caveat
The contract resolves a single auditable decision per (agent, user); it governs the entitlement decision and does not, on its own, satisfy any regulation. The zero-rating default reflects the June 2026 Microsoft Copilot Studio Licensing Guide (footnotes 6 & 7) — verified June 2026 and corroborated on Microsoft Learn — Copilot Studio billing and licensing — a Copilot-licensed user on a Microsoft 365 surface under their own identity is included in the Microsoft 365 Copilot User SL at no additional charge, subject to fair-usage limits. The generative-answer-with-tenant-grounding and beyond-fair-use refinements affect credit cost, not this allow/deny, and should be confirmed per tenant.
8. Key Configuration Point 6 — Configure per-agent metered spend caps (Zone 3)
For each Zone 3 metered agent, record a per-agent cap in fsi_cbgagentcap: fsi_agentid, fsi_monthlycreditcap, fsi_creditsconsumedmtd, fsi_enforcementmode (fsi_cbg_enforcementmode: Detect-and-alert = 100000000, Hard-stop = 100000001), fsi_capenforced, fsi_zoneclassification, fsi_lastevaluatedat. The alternate key is fsi_agentid.
Because there is no public write API to set an enforceable credit cap or hard spend-stop as of June 2026 (the Billing Policy CRUD API has no credit-cap field, and per-agent caps are Power Platform admin-center UI-managed), automation degrades to detect-and-alert. The helper records the cap and surfaces a month-to-date breach; it does not apply a programmatic hard-stop, and it does not report the cap as a hard-stop.
function Set-Agt227AgentCapRecord {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)][string]$AgentId,
[Parameter(Mandatory)][int]$MonthlyCreditCap,
[int]$CreditsConsumedMtd = 0,
[ValidateSet('Team (Zone 2)','Enterprise (Zone 3)')][string]$Zone = 'Enterprise (Zone 3)',
# Only set $true when a hard-stop write API has been confirmed for your tenant.
[bool]$HardStopApiConfirmed = $false
)
$mode = if ($HardStopApiConfirmed) { 'Hard-stop' } else { 'Detect-and-alert' }
$breached = $CreditsConsumedMtd -ge $MonthlyCreditCap
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'PerAgentCap' # manifest check 2.27.b
fsi_agentid = $AgentId
fsi_monthlycreditcap = $MonthlyCreditCap
fsi_creditsconsumedmtd = $CreditsConsumedMtd
fsi_enforcementmode = $mode # never 'Hard-stop' unless the API is confirmed
EnforcementIsHardStop = [bool]$HardStopApiConfirmed
fsi_capenforced = [bool]$HardStopApiConfirmed
fsi_zoneclassification = $Zone
BreachDetected = $breached
Status = if ($breached) { 'Anomaly' } else { 'Clean' }
Reason = if ($breached) { "Month-to-date consumption ($CreditsConsumedMtd) at or above cap ($MonthlyCreditCap). Detect-and-alert surfaces the breach; no programmatic hard-stop is applied." } else { $null }
fsi_lastevaluatedat = (Get-Date).ToUniversalTime().ToString('o')
}
}
Verify (manifest check
2.27.b). Evidence for criterion 5 is the cap-record export with the enforcement mode recorded per agent. No Zone 3 agent is documented as a hard-stop where the write API is unproven (defect 0.5):EnforcementIsHardStopstays$falseuntil a tenant-confirmed write API exists. Detect-and-alert surfaces breaches; it does not stop spend.
9. Key Configuration Point 7 — Run the pre-enforcement coverage-gap analysis (monitor-only)
Before any enforcement, produce the per-agent coverage-gap aggregate in monitor-only mode, review the would-be-blocked population, and obtain sign-off. The engine emits one fsi_cbgcoveragegap row per agent — aggregating per agent (not per agent × user) keeps the output bounded.
9.1 Coverage-gap field reference (fsi_cbgcoveragegap)
| Logical name | Meaning |
|---|---|
fsi_agentid / fsi_agentname |
Analyzed agent |
fsi_pathway |
Classified pathway |
fsi_eligibleusers |
Count of users not blocked |
fsi_blockeduserscount |
Count of intended users who would be blocked |
fsi_blockedsampleupns |
Capped JSON array of blocked UPNs (bounded by -SampleCap, default 20) |
fsi_blockreasonsummary |
Dominant block reason (fsi_cbg_blockreason) across the blocked cohort |
fsi_spendscope |
Surface-aware scope: Chat (100000000), SharePoint (100000001), Mixed (100000002) |
fsi_groupsizepartition |
Total intended-audience size (flags groups above threshold T, default 500) |
fsi_monitoronly |
true first — gap rows take no enforcement action |
fsi_analyzedat / fsi_retainuntil |
Analysis time and retention horizon |
"Blocked" semantics (engine
Test-DecisionIsAllow). A user counts as blocked in the coverage gap when the decision is not one ofAllow,Allow - Eligibility N/A, orFail-open - Anomaly. That means Fail-closed - Zero-rating Unresolved counts as blocked, while Fail-open - Anomaly does not (anunmapped-pathway user is permitted). Readfsi_blockeduserscountwith that rule in mind.
9.2 Confirm monitor-only and summarize the would-be-blocked population
function Get-Agt227CoverageGapReview {
[CmdletBinding()]
[OutputType([pscustomobject])]
param([Parameter(Mandatory)][string]$ResultPath) # entitlement-result.json from §7.2
$result = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json
$gaps = @($result.CoverageGaps)
$nonMonitor = @($gaps | Where-Object { $_.fsi_monitoronly -ne $true })
if ($nonMonitor.Count -gt 0) {
# defect 0.7 — not a valid pre-enforcement artifact
return [pscustomobject]@{
ControlId = '2.27'; Criterion = 'CoverageGap'; Status = 'Anomaly'
Reason = "$($nonMonitor.Count) coverage-gap row(s) are not monitor-only; this export cannot serve as a pre-enforcement sign-off artifact."
Timestamp = (Get-Date).ToUniversalTime()
}
}
$totalBlocked = ($gaps | Measure-Object -Property fsi_blockeduserscount -Sum).Sum
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'CoverageGap' # manifest check 2.27.c
AgentsAnalyzed = $gaps.Count
TotalWouldBeBlocked = [int]$totalBlocked
AgentsWithBlocks = @($gaps | Where-Object { $_.fsi_blockeduserscount -gt 0 }).Count
AllMonitorOnly = $true
Status = if ($gaps.Count -gt 0) { 'Clean' } else { 'NotApplicable' }
Timestamp = (Get-Date).ToUniversalTime()
}
}
9.3 Coverage-gap spend estimate (reference constants — not an invoice)
The engine carries per-feature Copilot credit reference rates for cost estimation. These are constants (not a Dataverse table), confirmed per Microsoft Learn (requirements-messages-management) as of June 2026 (pricing is time-sensitive — re-confirm against current Microsoft licensing documentation as it changes).
| Feature | Credits |
|---|---|
| Classic answer | 1 |
| Generative answer | 2 |
| Agent action | 5 |
| Tenant-graph grounding | 10 |
| Agent flow | 13 per 100 flow actions |
Pricing context (confirmed per Microsoft Learn — pay-as-you-go and requirements-messages-management, as of June 2026): $0.01 per credit; the prepaid pack is 25,000 credits per month ($200 per tenant per month), non-rolling (unused credits do not carry over). PAYG consumption meters against an Azure subscription with budget alerts only — not a hard-stop.
Verify (manifest check
2.27.c). Evidence for criteria 6 and 7 is the coverage-gap export withfsi_monitoronly = trueon all rows, the per-agent eligible / would-be-blocked counts, the capped UPN sample, and the dominant block reason — plus a documented sign-off on the would-be-blocked population and a spend estimate reconciled against the Microsoft 365 admin center and Azure cost reporting. These figures support cost estimation; they do not produce a billing-accurate invoice. Sign-off is required before any enforcement is activated.
10. Key Configuration Point 8 — Retain decisions and forward evidence
Persist the materialized decisions and coverage-gap aggregates, apply a retention horizon aligned to FINRA 4511 (six-year minimum) and SEC 17a-4(b)(4), and forward the records to the SIEM so the evidence is examination-ready.
10.1 Persist to Dataverse (mirrors the CBG-CoverageGapAnalyzer flow)
The engine is write-free; the CBG-CoverageGapAnalyzer flow is the canonical writer. For an operator-run evidence pass, the same idempotent upsert can be performed against the Dataverse Web API using the alternate keys defined in the schema ((fsi_agentid, fsi_userupn) for decisions; fsi_agentid for coverage gaps).
function Save-Agt227Evidence {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$ResultPath,
[Parameter(Mandatory)][string]$EnvironmentUrl,
[Parameter(Mandatory)][string]$DataverseToken
)
$base = $EnvironmentUrl.TrimEnd('/')
$headers = @{
Authorization = "Bearer $DataverseToken"
'OData-MaxVersion' = '4.0'
'OData-Version' = '4.0'
'Content-Type' = 'application/json'
'If-Match' = '*' # upsert on the alternate key
}
$result = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json
foreach ($d in @($result.Decisions)) {
$key = "fsi_cbgentitlementmaterializeds(fsi_agentid='$($d.fsi_agentid)',fsi_userupn='$($d.fsi_userupn)')"
if ($PSCmdlet.ShouldProcess($key, 'PATCH materialized decision')) {
Invoke-RestMethod -Method Patch -Uri "$base/api/data/v9.2/$key" -Headers $headers `
-Body ($d | ConvertTo-Json -Depth 6)
}
}
foreach ($g in @($result.CoverageGaps)) {
$key = "fsi_cbgcoveragegaps(fsi_agentid='$($g.fsi_agentid)')"
if ($PSCmdlet.ShouldProcess($key, 'PATCH coverage-gap row')) {
Invoke-RestMethod -Method Patch -Uri "$base/api/data/v9.2/$key" -Headers $headers `
-Body ($g | ConvertTo-Json -Depth 6)
}
}
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'EvidenceRetention'
Decisions = @($result.Decisions).Count
CoverageGaps = @($result.CoverageGaps).Count
Status = 'Clean'
Timestamp = (Get-Date).ToUniversalTime()
}
}
Each decision row already carries fsi_ttlexpiresat; each coverage-gap row carries fsi_retainuntil (the engine's -RetentionDays, default 183). For the FINRA 4511 six-year horizon, set -RetentionDays on the evaluation run accordingly and confirm the SIEM/archive retention policy — Dataverse and directory-native retention are far shorter than six years, so the SIEM/records archive is the system of record (defect 0.9).
10.2 SIEM forwarding
function Export-Agt227SiemBatch {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ResultPath,
[Parameter(Mandatory)][string]$SiemDropPath # Log Analytics ingestion / Event Hub stage / records archive
)
$result = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json
$batch = [pscustomobject]@{
controlId = '2.27'
evaluatedAt = $result.EvaluatedAt
zeroRatingResolved = $result.ZeroRatingResolved
decisions = $result.Decisions
coverageGaps = $result.CoverageGaps
forwardedAt = (Get-Date).ToUniversalTime().ToString('o')
}
$batch | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $SiemDropPath -Encoding UTF8
[pscustomobject]@{
ControlId = '2.27'
Criterion = 'SiemForwarding'
Records = @($result.Decisions).Count + @($result.CoverageGaps).Count
Destination = $SiemDropPath
RetentionGuidance = 'Retain >= 6 years (FINRA 4511) / SEC 17a-4(b)(4). Dataverse and directory-native retention are shorter than the examination horizon; the SIEM / records archive is the system of record.'
Status = 'Clean'
Timestamp = (Get-Date).ToUniversalTime()
}
}
Filtering boundary. Correlation, alerting, deduplication, and long-horizon retention belong to the SIEM and the records archive (pair with Control 1.7). This helper confirms the batch is staged for ingestion; it does not perform server-side filtering. Evidence for criterion 8 is the retention policy configuration plus SIEM ingestion confirmation.
11. Validation, anti-patterns, and operating cadence
11.1 Validation harness
function Test-Agt227PlaybookHealth {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$EnvironmentUrl)
$checks = @(
@{ Name = 'ShellEdition'; Test = { Assert-Agt227Shell; $true } }
@{ Name = 'ModulesPinned'; Test = { (Install-Agt227ModuleBaseline).Status -eq 'Clean' } }
@{ Name = 'Prerequisite'; Test = { (Test-Agt227Prerequisite).Status -in @('Clean','NotApplicable') } }
@{ Name = 'EnginePresent'; Test = { Test-Path "$cbgScripts\Invoke-EntitlementEvaluation.ps1" } }
@{ Name = 'InventoryPresent'; Test = { Test-Path "$cbgScripts\Get-BillingPolicyInventory.ps1" } }
)
foreach ($c in $checks) {
$ok = $false
try { $ok = & $c.Test } catch { $ok = $false }
[pscustomobject]@{ Check = $c.Name; Passed = $ok }
}
}
11.2 Anti-patterns table
| # | Anti-pattern | Why it fails | Sanctioned alternative |
|---|---|---|---|
| 11.1 | Treating an empty entitlement result as Clean |
A tenant with no Copilot licensing or an offline Work IQ feed produces an empty/undecided set | Check Test-Agt227Prerequisite and the FeedMissing / unmapped counts; NotApplicable is not Clean |
| 11.2 | Inventing a New-*BillingPolicy cmdlet for Copilot PAYG/credit policies |
No confirmed public create cmdlet exists; New-SPOAppBillingPolicy is SharePoint-only |
Create via the portal flows in §4; mark any REST create 🔎 until verified |
| 11.3 | Admitting a mail-enabled group into the registry | Violates the admission gate; eligibility silently "works" against the wrong group type | Test-Agt227GroupAdmission rejects mailEnabled / non-securityEnabled |
| 11.4 | Letting createdIn override an authoritative configuredTier |
Forces the none agent majority into the stricter license-gated mcp-cs arm |
configuredTier is evaluated first; createdIn is fallback only (§6) |
| 11.5 | Reporting a per-agent cap as a hard-stop | The cap write API is unproven; the row records intent only | Keep EnforcementMode='Detect-and-alert' and EnforcementIsHardStop=$false until a tenant-confirmed write API exists |
| 11.6 | Running coverage-gap after toggling enforcement | monitorOnly=false understates the would-be-blocked population |
Get-Agt227CoverageGapReview rejects any export with a non-monitor row |
| 11.7 | Presenting the credit-rate estimate as an invoice | Reference constants are not billing-accurate | State the estimate caveat; reconcile against the Microsoft 365 admin center / Azure cost reporting |
| 11.8 | Assuming Dataverse/native retention satisfies FINRA 4511 | Native retention is far shorter than six years | Forward to SIEM / records archive; set -RetentionDays and the archive policy to the six-year horizon |
| 11.9 | Hardcoding a client secret for unattended runs | Secret sprawl; not least-privilege | Managed-identity-first; client secret is a dev-only legacy fallback |
11.3 Operating cadence
| Cadence | Activity | Owner | Section |
|---|---|---|---|
| Daily | Run the entitlement evaluation (default + fail-closed shadow) and persist evidence | AI Administrator | §7, §10 |
| Daily | Review per-agent cap breaches surfaced by detect-and-alert | AI Administrator | §8 |
| Weekly | Sweep the security-group registry for admission violations | Entra User Admin | §5 |
| Weekly | Reconcile policy inventory against the 50 / 10 ceilings | AI Administrator | §4 |
| Before any enforcement | Coverage-gap review + would-be-blocked sign-off | AI Governance Lead + Finance / Controller + Business Unit Owner | §9 |
| Quarterly | Confirm retention and SIEM forwarding for examination readiness | Compliance Officer | §10 |
| Quarterly | Re-confirm pricing and ceilings against current Microsoft licensing documentation as they change (per-feature credit rates, $0.01/credit, 25,000-credit pack, tenant ceilings 50/10 were verified June 2026 — pricing is time-sensitive), re-check current portal/PPAC blade labels and the per-tenant COPILOT service-plan name, and watch for a public cap-enforcement write API should Microsoft ship one (caps remain detect-and-alert until then) |
AI Governance Lead | §0, §4, §7, §8, §9 |
11.4 Hedged language reminder
When documenting findings produced by these helpers, use only the framework's hedged phrasing:
- ✅ "Supports compliance with FINRA 4511 by retaining materialized entitlement decisions and coverage-gap evidence for the examination horizon."
- ✅ "Helps meet SOX 404 ITGC expectations by documenting who is entitled to incur metered spend and recording the approval of cap thresholds."
- ❌ "Ensures compliance with…" / "Guarantees…" / "Will prevent uncontrolled spend" / "Eliminates spend risk" — all overclaim.
Implementation caveat to retain in narrative reports:
"This control governs the entitlement decision and does not, on its own, satisfy any regulation. Implementation requires Microsoft 365 Copilot licensing, at least one consumption-billing policy, and the AI Administrator role. Zero-rating, credit rates, tenant ceilings, the June 16 2026 consumption-billing switch, and the existence of a cap/credit-policy write API should be verified against current Microsoft documentation before enforcement. Organizations should verify their configuration meets their specific obligations."
Cross-references
Control specification
Companion playbooks for this control
Companion solution (FSI-AgentGov-Solutions)
copilot-billing-governance/scripts/Invoke-EntitlementEvaluation.ps1— the switch-on-pathway engine + coverage-gap aggregatecopilot-billing-governance/scripts/Get-BillingPolicyInventory.ps1— PAYG + credit policy read (Dataverse + BAP)copilot-billing-governance/scripts/create_cbg_dataverse_schema.py— single source of truth for the table / column logical namescopilot-billing-governance/docs/entitlement-contract.md— decision tree, pseudocode, and zero-rating analysis
Related controls referenced in this playbook
- Control 3.5 — Cost Allocation and Budget Tracking — reports the spend this control governs
- Control 1.18 — Application-Level Authorization and RBAC — functional access is an input
- Control 1.14 — Data Minimization and Agent Scope Control — surface-aware spend scope
- Control 2.7 — Vendor and Third-Party Risk Management — third-party AI spend oversight
- Control 1.7 — Comprehensive Audit Logging & Compliance — SIEM forwarding and six-year retention
Updated: June 2026 | Version: v1.0 | UI Verification Status: Needs Review — the June 16 2026 Work IQ switch, Licensing Guide footnotes 6 & 7, the absence of a public cap/credit-policy write API, and the tenant ceilings (50/10) and per-feature credit rates ($0.01/credit, 25,000-credit pack) were verified June 2026; pricing is time-sensitive, so re-confirm figures against current Microsoft licensing documentation, and verify current portal/PPAC labels and the per-tenant COPILOT service-plan name (still shifting during the June 2026 rollout) before relying on enforcement.