PowerShell Setup — Control 2.12: Supervision and Oversight (FINRA Rule 3110)
Control under management:
2.12 — Supervision and Oversight (FINRA Rule 3110)Sister playbooks: Portal Walkthrough · Verification & Testing · Troubleshooting
Shared baseline (authoritative):
_shared/powershell-baseline.md— module pinning, sovereign endpoint matrix, mutation safety, evidence emission, SHA-256 manifest format. Read the baseline before running any command in this file.
This playbook automates the operational PowerShell surface for Control 2.12 — Supervision and Oversight (FINRA Rule 3110). It covers review-queue health monitoring, HITL reviewer-decision extraction, Microsoft Agent Framework request_info() evidence export, principal-registration (CRD) verification, sampling-protocol execution, the Rule 3120 annual-testing harness (Pester), Rule 2210 communication classification, quarterly sponsor attestation, the sovereign-cloud compensating-control runner, and SIEM forwarding. Every helper in this file uses the cmdlet prefix Sup212 so that operators can distinguish 2.12 helpers from sister controls (Agt225, Agt226, Orph36) at a glance in transcripts and SIEM rules.
Hedged-language reminder. Every helper and every evidence bundle in this playbook supports compliance with FINRA Rule 3110 (Supervision), FINRA Rule 3120 (Supervisory Control System), FINRA Rule 2210 (Communications with the Public), FINRA Rule 4511 (Books and Records), FINRA Regulatory Notice 24-09 (Gen AI / LLM Guidance), SEC Rules 17a-3 / 17a-4 (Recordkeeping and WORM), SOX §§ 302 / 404 (Internal Controls), and NYDFS 23 NYCRR 500. It does not "ensure" or "guarantee" compliance, and it does not substitute for the registered principal's supervisory review or for the firm's Written Supervisory Procedures (WSPs). In FINRA Rule 3110 terms, the scripts in this file produce the evidence the principal attests to — the principal's judgment remains the control.
Non-substitution anchor. Controls 2.25 and 2.26 reference this control as the supervisory anchor. Microsoft Agent 365 admin approvals and Entra Agent ID sponsorship are lifecycle / identity controls; they do not discharge the firm's Rule 3110 supervisory obligation. The helpers in this file treat Agent 365 approval history and Entra sponsorship state as inputs to principal supervision, never as replacements for it.
Sovereign cloud reality (April 2026)
FINRA Rule 3110 applies identically in commercial, GCC, GCC High, and DoD tenants. Several Microsoft surfaces this playbook reads, however, have parity gaps in sovereign clouds:
- Copilot Studio human-agent handoff and approval actions — verify availability in your sovereign tenant before relying on the §4 helpers.
- Microsoft Agent Framework HITL (
RequestPort/request_info()/ checkpointed pending requests) — the framework is available, but evidence-export integrations referenced in §6 may lag. - Microsoft Entra Agent ID sponsorship and Lifecycle Workflows — no announced sovereign-cloud GA as of April 2026 (see Control 2.26).
- Microsoft Agent 365 admin center — no announced sovereign-cloud GA (see Control 2.25).
Sovereign-tenant operators run the §12 compensating-control runner (Invoke-Sup212SovereignRegister) instead of §§4–6, and the §3 connection helper early-exits the commercial-only helpers with a structured SovereignCloudNotSupported status object (it does not throw, because sovereign operation is a supported mode of this control — it just runs a different path). Compensating-control evidence is routed to WORM-backed storage per Control 2.13 and audited under Control 3.4. See the shared baseline anchor: _shared/powershell-baseline.md#3-sovereign-cloud-endpoints-gcc-gcc-high-dod.
Scope
This playbook operationalizes the verification criteria enumerated in the Control 2.12 document (criteria #1–#9). It is not a substitute for reading that control; in particular, the definitions of "high-risk action", "qualified principal", "Zone 3", the communication-type decision tree, and the sampling-rate table live in the control document, not here. This file automates the evidence production and operating-effectiveness testing around those definitions.
| Verification criterion (Control 2.12) | Primary helper in this file |
|---|---|
| #1 WSP addendum coverage | Invoke-Sup212WspCoverageTest (§9.3) — metadata read only; authoring stays in the firm's DMS |
| #2 HITL configuration — Zone 3 commercial | Get-Sup212CopilotStudioHandoffConfig (§4.2), Get-Sup212AgentFrameworkHitlConfig (§6.2) |
| #3 Principal registration verification | Test-Sup212PrincipalRegistration (§7) |
| #4 Review queue SLA | Measure-Sup212ReviewQueueSla (§4.3) |
| #5 Reviewer-decision audit trail | Get-Sup212ReviewerDecisionAudit (§5) |
| #6 Rule 3120 annual test | Invoke-Sup212Rule3120Harness (§9) |
| #7 Rule 2210 classification evidence | Invoke-Sup212Rule2210Classifier (§10) |
| #8 Sovereign-cloud compensating control | Invoke-Sup212SovereignRegister (§12) |
| #9 Agent Framework evidence retention | Export-Sup212AgentFrameworkEvidence (§6.3) |
Audience
Primary operators are the AI Administrator (Microsoft admin-surface configuration and telemetry reads), the AI Governance Lead (orchestration, evidence-pack integrity, operating-effectiveness reporting), and the Compliance Officer (Rule 3120 testing sign-off, principal-registration attestation). The Designated Principal / Qualified Supervisor is the signer of evidence produced by these scripts, not an operator of them — the script produces the artifact; the principal reads and signs. The Agent Owner is responsible for ensuring each agent in scope is registered in Control 1.2 / Control 3.1 so that the §3–§5 helpers have a complete registry to iterate.
Canonical role names are defined in docs/reference/role-catalog.md. Use the short forms: Compliance Officer, Designated Principal / Qualified Supervisor, AI Governance Lead, AI Administrator, Agent Owner.
§0 — Wrong-Shell Trap and False-Clean Defect Catalogue
Before any helper in this playbook is dot-sourced, the operator must internalize the specific failure modes that produce false-clean supervisory signals. A false-clean signal on a supervision control is materially worse than a red signal: under FINRA Rule 3110 a documented gap is an exception to be remediated, but a false-clean record filed to WORM and signed by a principal is a misrepresentation of the firm's books and records under Rule 4511 and SEC 17a-4(b)(4). Every helper below is written to refuse empty results as evidence of a clean control state.
0.1 The wrong-shell trap
Copilot Studio admin reads, Power Automate approval-history reads, and Agent Framework event-stream reads all require the modern Microsoft Graph SDK on PowerShell 7.4+. Windows PowerShell 5.1 cannot load the pinned Microsoft.Graph 2.25 module family because of .NET Framework dependency mismatches, and it will silently load older 1.x versions if they are present on the operator's module path. Those older modules return @() for several of the agent surfaces this playbook reads, and @() is indistinguishable from "no flagged outputs this quarter" — the exact false-clean pattern this control is designed to prevent.
Assert-Sup212ShellHost (defined in §3.1) is the first action of every helper. It:
- Verifies
$PSVersionTable.PSEdition -eq 'Core'and$PSVersionTable.PSVersion -ge [version]'7.4'. - Verifies the loaded
Microsoft.Graph.Authenticationmodule is>= 2.25.0. - Verifies no
Microsoft.Graphmodule older than 2.25 is installed anywhere on$env:PSModulePath, because import-order ambiguity has bitten operators who have both 1.28 and 2.25 present side by side. - Verifies
ExchangeOnlineManagement >= 3.5.0andPester >= 5.5.0when §5 or §9 helpers are on the call stack. - On any failure, throws a terminating
[System.InvalidOperationException]whose message contains the literal tokenSup212-WrongShellso SIEM correlation rules can fire on it deterministically.
0.2 False-clean defect catalogue
| # | Defect | Symptom | Root cause | Structural guard in this playbook |
|---|---|---|---|---|
| 0.1 | Wrong PowerShell host | Get-MgAuditLog* returns @() |
PS 5.1 loaded Graph 1.28 alongside 2.25 | Assert-Sup212ShellHost (§3.1) |
| 0.2 | Sovereign tenant treated as commercial | §4–§6 helpers run, return Clean, but no Copilot Studio handoff surface exists |
Connect-MgGraph defaulted to -Environment Global against a .us or .mil tenant |
Resolve-Sup212CloudProfile (§3.2); sovereign callers are redirected to §12 |
| 0.3 | Read-only token used against audit surfaces | 403 swallowed, helper logs Anomaly then masks as Clean on retry |
Operator ran without Compliance Administrator PIM elevation | Test-Sup212GraphScopes (§3.4) preflights scope grants |
| 0.4 | Paged audit response truncated at 100 | Reviewer-decision count undercounts in active quarters | Operator forgot -All on Invoke-MgGraphRequest |
Invoke-Sup212PagedQuery (§3.5) paginates and asserts @odata.count |
| 0.5 | Throttled call returned empty body | Helper interprets HTTP 429 with empty JSON as zero decisions | No retry/backoff wrapper | Invoke-Sup212Throttled (§3.6) |
| 0.6 | Purview audit 30-minute ingestion lag | Recent HITL decisions appear missing | Unified audit log has up to a 30-minute (sometimes longer) ingestion delay | Get-Sup212ReviewerDecisionAudit (§5) accepts -LookbackBufferMinutes (default 45) and refuses to run against a -End within the buffer unless -AcceptIngestionLag is set |
| 0.7 | Power Automate approval history filtered by owner | Approvals initiated under a service-principal owner are invisible | Default Get-AdminFlowApproval scope is tenant-user-visible only |
Get-Sup212PowerAutomateApprovalHistory (§4.4) iterates all environments with -AdminMode and emits Anomaly if any environment returns 403 rather than silently skipping |
| 0.8 | Reviewer UPN null in audit row | Decision row written before approver context resolved; renders as (unknown) |
Race in M365 audit ingestion when approval auto-completes | Get-Sup212ReviewerDecisionAudit joins Get-MgAuditLogDirectoryAudit and emits Anomaly (never Clean) when UPN is null |
| 0.9 | Agent Framework request ID unmatched | Evidence export completes with zero rows for active flows | Operator queried the wrong workflow identifier or event stream | Export-Sup212AgentFrameworkEvidence (§6.3) cross-references the Agent Framework run manifest and emits Anomaly if request count is zero but run activity is non-zero |
| 0.10 | CRD lookup silently degraded | Principal registration "verified" against an empty cache | Automated CRD/WebCRD access not licensed; helper fell back to a manual file that was never populated | Test-Sup212PrincipalRegistration (§7) returns NotApplicable with Reason='CrdAccessNotAvailable' rather than Clean; operators must then run the documented manual-process path and populate the attestation store |
| 0.11 | Sampling RNG seeded with clock-second | Two parallel runs produce identical samples | Get-Random seeded with [int](Get-Date).Second |
Get-Sup212SamplePopulation (§8) uses [System.Security.Cryptography.RandomNumberGenerator] and records the seed in the evidence manifest |
| 0.12 | Pester harness marked green on Skipped |
Rule 3120 report shows all tests "passing" though half ran -Skip |
Operator used -SkipRemainingOnFailure or environment gating without auditing skip count |
Invoke-Sup212Rule3120Harness (§9) refuses to emit Clean if Skipped > 0; skip count is surfaced in the test report header |
| 0.13 | Access review auto-approved on reviewer non-response | Quarterly sponsor attestation shows 100% completion with zero real decisions | Default Entra access-review setting: "If reviewers don't respond: Approve" | Get-Sup212SponsorAttestation (§11) surfaces defaultDecision and defaultDecisionEnabled and flags auto-approval as Anomaly |
| 0.14 | SIEM forwarding silent drop | Supervision events absent from Sentinel | Data Collection Rule filters out the relevant Operation values |
Test-Sup212SiemForwarding (§13) emits a canary event with a known correlation ID and verifies it appears in the target workspace; no appearance → Anomaly, not Clean |
| 0.15 | Empty result conflated with "no findings" | Helper returns $null or @(); downstream evidence pack says "Clean" |
Helpers must distinguish Clean from NotApplicable from Error |
Every helper returns [pscustomobject] with explicit Status ∈ {Clean, Anomaly, Pending, NotApplicable, Error} and non-empty Reason whenever Status -ne 'Clean' |
Operator discipline. Every helper in §§3–13 returns a structured object with a
Statusfield. No helper ever returns$nullor@()as a clean signal. When there is genuinely no data to report (e.g., no HITL decisions in a quarter because the tenant has no Zone 3 agents), the helper returns a single object withStatus='NotApplicable'and a populatedReason— and the §14 evidence packer requires an explicit affirmative affirmation in the evidence manifest thatNotApplicablereflects reality, not an instrumentation gap.
§1 — Module Inventory, Graph Scopes, and RBAC Matrix
1.1 Module pinning
Every module this playbook uses is pinned against the version verified during the April 2026 UI-verification pass. Operators in regulated tenants must install from an internal PSGallery mirror rather than the public gallery directly; substitute -Repository <YourInternalFeed> and verify the package SHA-256 against the values published in _shared/powershell-baseline.md.
#Requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$pinned = @{
'Microsoft.Graph' = '2.25.0'
'Microsoft.Graph.Beta' = '2.25.0'
'Microsoft.Graph.Authentication' = '2.25.0'
'ExchangeOnlineManagement' = '3.5.0'
'Microsoft.PowerApps.Administration.PowerShell' = '2.0.188'
'Pester' = '5.5.0'
'Az.Accounts' = '2.15.0'
'Az.OperationalInsights' = '3.6.0'
}
foreach ($name in $pinned.Keys) {
$installed = Get-Module -ListAvailable -Name $name |
Where-Object { $_.Version -eq [version]$pinned[$name] }
if (-not $installed) {
Install-Module -Name $name `
-RequiredVersion $pinned[$name] `
-Repository PSGallery `
-Scope CurrentUser `
-AllowClobber `
-AcceptLicense `
-Force
}
}
Import-Module Microsoft.Graph.Authentication -RequiredVersion 2.25.0 -Force
If your tenant lacks one of the above modules (for example, sovereign tenants rarely install Microsoft.PowerApps.Administration.PowerShell because Power Platform admin surfaces differ), Assert-Sup212ShellHost surfaces the gap structurally rather than masking it — do not comment the module out of the pinned list without updating the skip rules in §9.
1.2 Graph scope matrix
| Scope | Required for | Helper | Failure mode if missing |
|---|---|---|---|
AuditLog.Read.All |
Reviewer-decision audit, Agent Framework event stream | Get-Sup212ReviewerDecisionAudit, Export-Sup212AgentFrameworkEvidence |
403 on audit reads; helpers emit Error with Sup212-MissingScope |
Directory.Read.All |
Resolving reviewer UPNs, principal-directory lookups | Resolve-Sup212ReviewerUpn |
Reviewer column rendered as (unresolved); helper emits Anomaly |
Application.Read.All |
Resolving agent app registrations | Get-Sup212AgentInventoryJoin |
403 on /applications reads |
Policy.Read.All |
Conditional Access policies for HITL gating | Get-Sup212HitlConditionalAccessPolicy |
Helper emits NotApplicable with Reason='Policy.Read.AllNotGranted' |
AccessReview.Read.All |
Quarterly sponsor-attestation reads | Get-Sup212SponsorAttestation |
Helper emits NotApplicable — not Clean — so that the sponsor path is not silently skipped |
AgentIdentity.Read.All |
Sponsorship relationship enumeration (where tenant exposes it) | Get-Sup212SponsorAttestation |
Helper degrades to directory-only enumeration, tags Reason='AgentIdentityScopeUnavailable' |
Test-Sup212GraphScopes (§3.4) interrogates the live token and returns one row per required scope with a Granted boolean; the bootstrap aborts unless all scopes except AgentIdentity.Read.All are granted.
1.3 Power Platform / Exchange / Az scope matrix
| API surface | Required for | Role / license |
|---|---|---|
Microsoft.PowerApps.Administration.PowerShell — Add-PowerAppsAccount -Endpoint prod (or -Endpoint usgov, usgovhigh, dod) |
Power Automate approval history (§4.4) | Power Platform Administrator (PIM-elevated) |
ExchangeOnlineManagement — Connect-ExchangeOnline then Search-UnifiedAuditLog |
Copilot Studio transcript metadata and cross-check (§4.2) | Compliance Administrator (PIM-elevated); AuditLog role on the EXO principal |
Az.OperationalInsights — Invoke-AzOperationalInsightsQuery |
Sentinel forwarding verification (§13) | Reader on the Log Analytics workspace |
| Agent Framework evidence endpoint (HTTPS) | request_info() event stream export (§6) |
App registration with certificate-based auth; the endpoint URL is firm-specific and must be configured in §3.7 |
1.4 RBAC matrix
| Activity | Minimum role | PIM elevation | Notes |
|---|---|---|---|
| Read Copilot Studio handoff configuration | Power Platform Administrator (read) | No — for read only | PIM required if the same session mutates environment settings |
| Read Power Automate approval history (admin mode) | Power Platform Administrator | Yes | -AdminMode toggles the tenant-wide scope |
| Read unified audit log (Purview) | Compliance Administrator | Yes | Required for §5 and §6 |
| Read Entra directory / app registrations | Entra Global Reader | No | Sufficient for §4 config reads |
| Run Rule 3120 Pester harness | AI Governance Lead (no admin role needed for pure test logic) | No | The harness consumes evidence files; it does not call admin APIs |
| Execute sovereign compensating register | Designated Principal (signer) + AI Administrator (executor) | No — manual ceremony | Dual-signature required |
| Forward evidence to Sentinel | Reader on workspace + Monitoring Metrics Publisher on DCE | No | Out-of-scope for Entra RBAC |
Canonical role names: Compliance Officer, Designated Principal / Qualified Supervisor, AI Governance Lead, AI Administrator, Agent Owner. See docs/reference/role-catalog.md.
1.5 PIM elevation pattern
Compliance Administrator and Power Platform Administrator are PIM-bound for audit reads because these roles can also be used to export customer content at scale; justification capture is required. Every §5, §6, and §11 helper captures the PIM elevation ticket in its evidence manifest:
function Get-Sup212PimJustification {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $RoleName,
[Parameter(Mandatory)] [string] $TicketId,
[Parameter(Mandatory)] [string] $Justification
)
if ($Justification.Length -lt 24) {
throw "Sup212-WeakJustification: PIM justification must be >= 24 chars; got $($Justification.Length)."
}
if ($TicketId -notmatch '^(CHG|INC|TASK|JIRA-)\S+$') {
throw "Sup212-BadTicketId: Expected CHG*, INC*, TASK*, or JIRA-* format; got '$TicketId'."
}
[pscustomobject]@{
Role = $RoleName
TicketId = $TicketId
Justification = $Justification
CapturedAt = (Get-Date).ToUniversalTime().ToString('o')
}
}
Every evidence bundle in this playbook references the PimJustification object that was live at the moment of capture. Omitting the justification object fails the §14 evidence-pack integrity check.
§2 — Preview Gating and Cmdlet Availability
Between the November 2025 Copilot Studio preview and the April 2026 verification pass, Microsoft renamed several cmdlets and surfaces. Two classes of defects follow from mid-flight renames: (a) missing cmdlets swallowed by a broad try/catch and masked as Clean; (b) preview surfaces treated as GA and relied on for principal evidence. Every helper that calls a module cmdlet first invokes Get-Sup212CmdletAvailability:
function Get-Sup212CmdletAvailability {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string[]] $CmdletName
)
foreach ($name in $CmdletName) {
$cmd = Get-Command -Name $name -ErrorAction SilentlyContinue
[pscustomobject]@{
CmdletName = $name
Available = [bool]$cmd
Module = if ($cmd) { $cmd.ModuleName } else { $null }
Version = if ($cmd) { $cmd.Module.Version.ToString() } else { $null }
}
}
}
When a required cmdlet is unavailable, the calling helper emits Status='NotApplicable', Reason="CmdletMissing:<name>", and a portal-export fallback URI in the evidence record — it never silently returns Clean.
2.1 Preview-gating preflight
Agent 365 autonomous-agent identities remain in Preview at the May 1, 2026 GA milestone (see Control 2.12 control document § "Autonomous Agents, Zone 3, and Agent 365 Preview Scope"). This playbook does not emit any helper that relies on autonomous-agent preview surfaces; Test-Sup212PreviewGating returns NotApplicable with Reason='AutonomousAgentsOutOfScope' if an operator attempts to pass -IncludeAutonomous.
function Test-Sup212PreviewGating {
[CmdletBinding()]
param(
[switch] $IncludeAutonomous
)
if ($IncludeAutonomous) {
return [pscustomobject]@{
Surface = 'AutonomousAgentIdentities'
Status = 'NotApplicable'
Reason = 'AutonomousAgentsOutOfScope — see Control 2.12 control doc and Control 2.25 preview-gating note.'
}
}
[pscustomobject]@{
Surface = 'CopilotStudioHitl,AgentFrameworkHitl,PowerAutomateApprovals'
Status = 'Clean'
Reason = ''
}
}
§3 — Connection Helper (Commercial + Sovereign)
The bootstrap helpers establish the session, decide which Microsoft Graph / Exchange / Power Platform environment to target, validate scopes, capture the PIM justification, and emit a structured Sup212Session object that every subsequent helper consumes. Four rules are non-negotiable:
- Every session is initialized with
Disconnect-*first so cached cross-tenant tokens cannot leak into evidence. - Sovereign tenants are detected and routed — the commercial helpers emit
Status='NotApplicable'and return a redirect object pointing at §12; they do not throw, because sovereign operation is a supported mode of this control. - Throttling is wrapped at the bootstrap layer so §4–§13 callers never need to write their own retry loops.
- Every session carries a
RunId(a GUID generated atInitialize-Sup212Session) that is emitted into every evidence file, every audit correlation, and every SIEM record. TheRunIdis the unit of evidence integrity.
3.1 Assert-Sup212ShellHost
function Assert-Sup212ShellHost {
[CmdletBinding()]
param(
[switch] $RequirePester,
[switch] $RequireExchange,
[switch] $RequirePowerPlatform
)
if ($PSVersionTable.PSEdition -ne 'Core') {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: PowerShell Core (7.4+) required; got $($PSVersionTable.PSEdition)")
}
if ($PSVersionTable.PSVersion -lt [version]'7.4') {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: PS 7.4+ required; got $($PSVersionTable.PSVersion)")
}
$auth = Get-Module -Name Microsoft.Graph.Authentication -ListAvailable |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $auth -or $auth.Version -lt [version]'2.25.0') {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: Microsoft.Graph.Authentication 2.25.0+ required.")
}
$stale = Get-Module -ListAvailable -Name Microsoft.Graph |
Where-Object { $_.Version -lt [version]'2.25.0' }
if ($stale) {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: Stale Microsoft.Graph $($stale[0].Version) present alongside 2.25; remove stale version to remove import-order ambiguity.")
}
$loadedDesktopGraph = Get-Module -Name 'Microsoft.Graph*' |
Where-Object { $_.Path -match 'WindowsPowerShell\\v1\.0' }
if ($loadedDesktopGraph) {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: Microsoft.Graph modules loaded from Windows PowerShell 5.1 path; shell is contaminated. Restart pwsh 7.4 cleanly.")
}
if ($RequirePester) {
$pester = Get-Module -Name Pester -ListAvailable |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $pester -or $pester.Version -lt [version]'5.5.0') {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: Pester 5.5+ required for Rule 3120 harness.")
}
}
if ($RequireExchange) {
$exo = Get-Module -Name ExchangeOnlineManagement -ListAvailable |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $exo -or $exo.Version -lt [version]'3.5.0') {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: ExchangeOnlineManagement 3.5+ required for unified audit reads.")
}
}
if ($RequirePowerPlatform) {
$pp = Get-Module -Name Microsoft.PowerApps.Administration.PowerShell -ListAvailable |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $pp) {
throw [System.InvalidOperationException]::new(
"Sup212-WrongShell: Microsoft.PowerApps.Administration.PowerShell required for Power Automate approval history reads.")
}
}
[pscustomobject]@{
Status = 'Clean'
PSVersion = $PSVersionTable.PSVersion.ToString()
GraphVersion = $auth.Version.ToString()
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
Reason = ''
}
}
3.2 Resolve-Sup212CloudProfile
This helper resolves to one of the canonical Graph / EXO / Power Platform environments. Sovereign tenants are flagged, not rejected — the helper returns a Sovereign=$true profile with RedirectTo='Sup212Sovereign' so that the orchestrator in §3.3 takes the §12 compensating-control path. Throwing would tempt sovereign operators to catch-and-ignore; redirecting preserves the operating rhythm while changing the evidence pipeline. See the sovereign-cloud anchor in the baseline.
function Resolve-Sup212CloudProfile {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $TenantId,
[ValidateSet('Auto','Global','USGov','USGovHigh','USGovDoD','China','Germany')]
[string] $Override = 'Auto',
[string] $TenantDomainHint
)
$envName = if ($Override -ne 'Auto') {
$Override
} else {
switch -Regex ($TenantDomainHint) {
'\.mil$' { 'USGovDoD'; break }
'\.us$' { 'USGovHigh'; break }
'onmicrosoft\.us$' { 'USGov'; break }
'onmschina' { 'China'; break }
'onmicrosoft\.de$' { 'Germany'; break }
default { 'Global' }
}
}
$profile = switch ($envName) {
'Global' { @{ GraphEnv='Global'; ExoEnv='O365Default'; PowerAppsEndpoint='prod'; Sovereign=$false } }
'USGov' { @{ GraphEnv='USGov'; ExoEnv='O365USGovGCC'; PowerAppsEndpoint='usgov'; Sovereign=$true } }
'USGovHigh' { @{ GraphEnv='USGov'; ExoEnv='O365USGovHigh'; PowerAppsEndpoint='usgovhigh';Sovereign=$true } }
'USGovDoD' { @{ GraphEnv='USGovDoD'; ExoEnv='O365USGovDoD'; PowerAppsEndpoint='dod'; Sovereign=$true } }
'China' { @{ GraphEnv='China'; ExoEnv='O365China'; PowerAppsEndpoint='china'; Sovereign=$true } }
'Germany' { @{ GraphEnv='Germany'; ExoEnv='O365GermanyCloud'; PowerAppsEndpoint='germany'; Sovereign=$true } }
}
[pscustomobject]@{
TenantId = $TenantId
EnvName = $envName
GraphEnv = $profile.GraphEnv
ExoEnv = $profile.ExoEnv
PowerAppsEndpoint = $profile.PowerAppsEndpoint
Sovereign = $profile.Sovereign
RedirectTo = if ($profile.Sovereign) { 'Sup212Sovereign' } else { $null }
ResolvedAt = (Get-Date).ToUniversalTime().ToString('o')
}
}
3.3 Initialize-Sup212Session
function Initialize-Sup212Session {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $TenantId,
[string] $TenantDomainHint,
[string] $RunId = ([guid]::NewGuid().ToString()),
[Parameter(Mandatory)] [pscustomobject] $PimJustification,
[string[]] $RequestedScopes = @(
'AuditLog.Read.All',
'Directory.Read.All',
'Application.Read.All',
'Policy.Read.All',
'AccessReview.Read.All',
'AgentIdentity.Read.All'
),
[switch] $ConnectExchange,
[switch] $ConnectPowerPlatform,
[ValidateSet('Auto','Global','USGov','USGovHigh','USGovDoD','China','Germany')]
[string] $CloudOverride = 'Auto'
)
$null = Assert-Sup212ShellHost `
-RequireExchange:$ConnectExchange `
-RequirePowerPlatform:$ConnectPowerPlatform
$cloud = Resolve-Sup212CloudProfile `
-TenantId $TenantId `
-TenantDomainHint $TenantDomainHint `
-Override $CloudOverride
if ($cloud.Sovereign) {
Write-Warning "Sup212: sovereign tenant detected ($($cloud.EnvName)). Commercial-only helpers will return Status='NotApplicable' with RedirectTo='Sup212Sovereign'. Run Invoke-Sup212SovereignRegister from §12 for the compensating-control path."
}
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Connect-MgGraph -TenantId $TenantId -Scopes $RequestedScopes `
-Environment $cloud.GraphEnv -NoWelcome -ErrorAction Stop
$scopeReport = Test-Sup212GraphScopes -RequestedScopes $RequestedScopes
$missing = $scopeReport | Where-Object {
-not $_.Granted -and $_.Scope -ne 'AgentIdentity.Read.All'
}
if ($missing) {
Disconnect-MgGraph | Out-Null
throw [System.UnauthorizedAccessException]::new(
"Sup212-MissingScopes: $($missing.Scope -join ', ')")
}
if ($ConnectExchange) {
Connect-ExchangeOnline -ExchangeEnvironmentName $cloud.ExoEnv `
-ShowBanner:$false -ErrorAction Stop
}
if ($ConnectPowerPlatform) {
Add-PowerAppsAccount -Endpoint $cloud.PowerAppsEndpoint | Out-Null
}
[pscustomobject]@{
RunId = $RunId
TenantId = $TenantId
Cloud = $cloud
ScopesGranted = ($scopeReport | Where-Object Granted).Scope
ScopesMissing = ($scopeReport | Where-Object { -not $_.Granted }).Scope
PimJustification = $PimJustification
ExchangeConnected = [bool]$ConnectExchange
PowerPlatformConnected = [bool]$ConnectPowerPlatform
InitializedAt = (Get-Date).ToUniversalTime().ToString('o')
Status = 'Clean'
Reason = if ($cloud.Sovereign) { 'SovereignRedirectActive' } else { '' }
}
}
3.4 Test-Sup212GraphScopes
function Test-Sup212GraphScopes {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string[]] $RequestedScopes
)
$ctx = Get-MgContext
if (-not $ctx) {
throw [System.InvalidOperationException]::new(
"Sup212-NoContext: Connect-MgGraph has not been called.")
}
$granted = $ctx.Scopes
foreach ($scope in $RequestedScopes) {
[pscustomobject]@{
Scope = $scope
Granted = ($granted -contains $scope)
}
}
}
3.5 Invoke-Sup212PagedQuery
Every Graph audit read in this playbook uses this helper so that paging is asserted, not assumed. It refuses to return partial results when @odata.count disagrees with the assembled row count.
function Invoke-Sup212PagedQuery {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Uri,
[int] $MaxPages = 1000
)
$rows = [System.Collections.Generic.List[object]]::new()
$page = 0
$next = $Uri
$expected = $null
while ($next -and $page -lt $MaxPages) {
$page++
$resp = Invoke-Sup212Throttled {
Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject
}
if ($null -ne $resp.'@odata.count' -and $null -eq $expected) {
$expected = [int]$resp.'@odata.count'
}
if ($resp.value) { $rows.AddRange($resp.value) }
$next = $resp.'@odata.nextLink'
}
if ($null -ne $expected -and $rows.Count -ne $expected) {
return [pscustomobject]@{
Status = 'Anomaly'
Reason = "Sup212-PagingMismatch: expected=$expected got=$($rows.Count)"
Rows = $rows
Pages = $page
}
}
[pscustomobject]@{
Status = 'Clean'
Reason = ''
Rows = $rows
Pages = $page
}
}
3.6 Invoke-Sup212Throttled
$script:Sup212ThrottleState = @{
Retries = 5
MaxBackoff = [TimeSpan]::FromSeconds(60)
}
function Invoke-Sup212Throttled {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [scriptblock] $ScriptBlock
)
$attempt = 0
$backoff = [TimeSpan]::FromSeconds(1)
while ($true) {
try {
return & $ScriptBlock
} catch {
$attempt++
$status = $_.Exception.Response.StatusCode.value__
$isRetry = ($status -in 429, 503, 504) -or ($_.Exception.Message -match 'timed out')
if (-not $isRetry -or $attempt -ge $script:Sup212ThrottleState.Retries) {
throw
}
$retryAfter = $_.Exception.Response.Headers.RetryAfter.Delta
$sleep = if ($retryAfter) { $retryAfter } else { $backoff }
if ($sleep -gt $script:Sup212ThrottleState.MaxBackoff) {
$sleep = $script:Sup212ThrottleState.MaxBackoff
}
Write-Verbose "Sup212-Throttle: attempt=$attempt status=$status sleep=$($sleep.TotalSeconds)s"
Start-Sleep -Seconds ([int]$sleep.TotalSeconds)
$backoff = [TimeSpan]::FromSeconds([math]::Min($backoff.TotalSeconds * 2, 60))
}
}
}
3.7 Configuration file for endpoint-dependent helpers
The Agent Framework event stream, Sentinel data-collection endpoint, and supervision-register list URL are firm-specific. Capture them once in sup212.config.json next to the playbook; the §6, §11, and §13 helpers require the file. Missing fields are surfaced as NotApplicable, not Clean.
{
"agentFrameworkEvidenceEndpoint": "https://agf-evidence.contoso.corp/api/v1/supervision/requests",
"agentFrameworkClientId": "00000000-0000-0000-0000-000000000000",
"agentFrameworkCertThumbprint": "",
"supervisionRegisterListUrl": "https://contoso.sharepoint.com/sites/ai-governance/Lists/SupervisionRegister",
"sentinelWorkspaceId": "",
"sentinelDataCollectionEndpointUri": "",
"sentinelDcrImmutableId": "",
"sentinelStreamName": "Custom-Sup212Supervision_CL"
}
3.8 New-Sup212EvidenceManifest
Every section that writes an artifact to disk emits an evidence record through this helper. The manifest format aligns with the SHA-256 manifest in the shared baseline.
function New-Sup212EvidenceManifest {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [string] $Criterion,
[Parameter(Mandatory)] [string] $Status,
[string] $Reason = '',
[string[]] $Artifacts = @(),
[string[]] $RegulatorMappings = @('FINRA-3110','FINRA-3120','FINRA-2210','FINRA-4511','SEC-17a-4','SOX-404'),
[hashtable] $Extras = @{}
)
$hashes = foreach ($path in $Artifacts) {
if (Test-Path $path) {
[pscustomobject]@{
path = $path
sha256 = (Get-FileHash -Path $path -Algorithm SHA256).Hash
bytes = (Get-Item $path).Length
}
}
}
$manifest = [ordered]@{
control_id = '2.12'
run_id = $Session.RunId
run_timestamp = (Get-Date).ToUniversalTime().ToString('o')
tenant_id = $Session.TenantId
cloud = $Session.Cloud.EnvName
sovereign = $Session.Cloud.Sovereign
namespace = 'fsi-agentgov.supervision.rule3110'
criterion = $Criterion
status = $Status
reason = $Reason
evidence_artifacts = $hashes
regulator_mappings = $RegulatorMappings
pim_justification = $Session.PimJustification
schema_version = '1.0'
}
foreach ($k in $Extras.Keys) { $manifest[$k] = $Extras[$k] }
[pscustomobject]$manifest
}
§4 — Review Queue Health Monitoring (Verification Criteria #2 and #4)
The supervisory review queue is the operational surface over which FINRA Rule 3110 supervision runs in the Microsoft estate. Copilot Studio's human-agent handoff and approval actions patterns route outputs matching the firm-defined high-risk criteria to a qualified reviewer; Power Automate approval actions fill equivalent roles for workflows outside Copilot Studio. §4 produces two artifacts:
- A configuration-export bundle (criterion #2) that proves each Zone 3 agent has HITL wiring present, the trigger criteria match the WSP, and a test transcript exists.
- A SLA measurement report (criterion #4) with median and 95th-percentile time-to-review per zone, and per-agent exception rows for decisions that breached SLA — flagged as incidents under Control 3.4.
Both artifacts are signed, hashed, and emitted through New-Sup212EvidenceManifest.
4.1 Get-Sup212Zone3AgentInventory
Scope for the queue helpers is bounded by the authoritative agent registry maintained in Control 1.2 and Control 3.1. This helper reads the Zone 3 subset from a firm-configured registry URL (defined in sup212.config.json) and returns a typed collection. It refuses to run without a registry — a script that does not know which agents are in scope cannot produce supervision evidence.
function Get-Sup212Zone3AgentInventory {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [string] $RegistryCsvPath
)
if ($Session.Cloud.Sovereign) {
return [pscustomobject]@{
Status = 'NotApplicable'
Reason = 'SovereignRedirectActive — run Invoke-Sup212SovereignRegister (§12) for sovereign evidence.'
RedirectTo = 'Sup212Sovereign'
}
}
if (-not (Test-Path $RegistryCsvPath)) {
return [pscustomobject]@{
Status = 'Error'
Reason = "Sup212-NoRegistry: registry CSV not found at $RegistryCsvPath. Populate from Control 3.1 export."
}
}
$rows = Import-Csv -Path $RegistryCsvPath |
Where-Object { $_.Zone -eq '3' -and $_.Status -eq 'Active' }
if (-not $rows -or $rows.Count -eq 0) {
return [pscustomobject]@{
Status = 'NotApplicable'
Reason = 'Tenant has no Active Zone 3 agents registered under Control 3.1. Confirm this reflects reality with Agent Owner before signing.'
Rows = @()
}
}
[pscustomobject]@{
Status = 'Clean'
Reason = ''
Rows = $rows
}
}
4.2 Get-Sup212CopilotStudioHandoffConfig
For each Zone 3 agent, this helper reads the Copilot Studio bot definition (via the Power Platform admin API) and extracts the handoff / approval configuration. The helper emits Anomaly — not Clean — when the configuration is missing, when the trigger criteria are empty, or when no test transcript exists.
function Get-Sup212CopilotStudioHandoffConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $Agents,
[string] $TestTranscriptRoot = './evidence/2.12/handoff-transcripts'
)
if ($Session.Cloud.Sovereign) {
return [pscustomobject]@{
Status='NotApplicable'; Reason='SovereignRedirectActive'; RedirectTo='Sup212Sovereign'
}
}
$results = foreach ($agent in $Agents) {
try {
$envId = $agent.PowerPlatformEnvironmentId
$botId = $agent.CopilotStudioBotId
$def = Invoke-Sup212Throttled {
Get-PowerAppEnvironment -EnvironmentName $envId |
Out-Null
# Copilot Studio bot definition export — replace with documented cmdlet or REST call per your tenant:
Invoke-MgGraphRequest -Method GET `
-Uri "https://api.powerplatform.com/copilotstudio/environments/$envId/bots/$botId?api-version=2025-04-01"
}
$handoff = $def.properties.supervision.handoff
$approvals = $def.properties.supervision.approvalActions
$transcriptPath = Join-Path $TestTranscriptRoot "$botId.transcript.json"
$transcriptPresent = Test-Path $transcriptPath
$isMissing = -not $handoff -and -not $approvals
$isEmpty = ($handoff -and -not $handoff.triggers) -or
($approvals -and -not $approvals.triggers)
$status = if ($isMissing) { 'Anomaly' }
elseif ($isEmpty) { 'Anomaly' }
elseif (-not $transcriptPresent) { 'Anomaly' }
else { 'Clean' }
$reason = switch ($status) {
'Anomaly' {
if ($isMissing) { 'No handoff or approval configuration found.' }
elseif ($isEmpty) { 'Handoff / approval configuration present but trigger list empty.' }
elseif (-not $transcriptPresent) { "Test transcript missing at $transcriptPath." }
}
default { '' }
}
[pscustomobject]@{
AgentId = $agent.AgentId
AgentName = $agent.AgentName
BotId = $botId
HandoffConfigured = [bool]$handoff
ApprovalConfigured = [bool]$approvals
HandoffTriggers = if ($handoff) { $handoff.triggers -join ';' } else { '' }
ApprovalTriggers = if ($approvals) { $approvals.triggers -join ';' } else { '' }
TestTranscriptPath = if ($transcriptPresent) { $transcriptPath } else { '' }
Status = $status
Reason = $reason
}
} catch {
[pscustomobject]@{
AgentId = $agent.AgentId; AgentName = $agent.AgentName; BotId = $agent.CopilotStudioBotId
HandoffConfigured=$null; ApprovalConfigured=$null; HandoffTriggers=''; ApprovalTriggers=''
TestTranscriptPath=''; Status='Error'; Reason=$_.Exception.Message
}
}
}
$results
}
Note on the Copilot Studio export endpoint. The exact REST URI for programmatic Copilot Studio bot-definition export is subject to change between Copilot Studio releases; verify it against current Microsoft Learn documentation and your tenant's admin API version before scheduling this helper unattended. The §4.2 helper treats a 404 on that URI as Error, not Clean.
4.3 Measure-Sup212ReviewQueueSla (Criterion #4)
This helper aggregates decision latency from Power Automate approval history and Copilot Studio transcripts into a single SLA report. Median, 95th-percentile, and per-zone thresholds come from the firm's WSP; this helper accepts them as parameters and does not hard-code a rate.
function Measure-Sup212ReviewQueueSla {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [datetime] $Start,
[Parameter(Mandatory)] [datetime] $End,
[Parameter(Mandatory)] [hashtable] $SlaMinutesByZone, # @{ '2' = 480; '3' = 60 }
[string] $OutputPath = "./evidence/2.12/queue-sla-$($Session.RunId).json"
)
if ($Session.Cloud.Sovereign) {
return [pscustomobject]@{
Status='NotApplicable'; Reason='SovereignRedirectActive'; RedirectTo='Sup212Sovereign'
}
}
$decisions = Get-Sup212PowerAutomateApprovalHistory -Session $Session -Start $Start -End $End
if ($decisions.Status -ne 'Clean' -and $decisions.Status -ne 'NotApplicable') {
return [pscustomobject]@{
Status = 'Error'
Reason = "ApprovalHistoryFailed: $($decisions.Reason)"
}
}
if ($decisions.Rows.Count -eq 0) {
$empty = [pscustomobject]@{
Status = 'NotApplicable'
Reason = 'No approval decisions in window. Confirm this reflects reality before signing — zero can be genuine (no Zone 3 activity) or an instrumentation gap.'
Window = @{ Start=$Start.ToString('o'); End=$End.ToString('o') }
}
$empty | ConvertTo-Json -Depth 6 | Out-File -FilePath $OutputPath -Encoding utf8
return $empty
}
$rows = $decisions.Rows | ForEach-Object {
$latency = ($_.ResolvedAtUtc - $_.CreatedAtUtc).TotalMinutes
$zone = $_.AgentZone
$threshold = $SlaMinutesByZone[$zone]
$breached = if ($threshold) { $latency -gt $threshold } else { $false }
[pscustomobject]@{
ApprovalId = $_.ApprovalId
AgentId = $_.AgentId
AgentZone = $zone
CreatedAtUtc = $_.CreatedAtUtc
ResolvedAtUtc= $_.ResolvedAtUtc
LatencyMin = [math]::Round($latency, 2)
Threshold = $threshold
Breached = $breached
ReviewerUpn = $_.ReviewerUpn
Decision = $_.Decision
}
}
$byZone = $rows | Group-Object AgentZone | ForEach-Object {
$lats = $_.Group.LatencyMin | Sort-Object
$n = $lats.Count
$median = if ($n -gt 0) { $lats[[int][math]::Floor($n/2)] } else { $null }
$p95idx = if ($n -gt 0) { [int][math]::Ceiling(0.95 * $n) - 1 } else { 0 }
$p95 = if ($n -gt 0) { $lats[[math]::Max(0, $p95idx)] } else { $null }
$breachCount = ($_.Group | Where-Object Breached).Count
[pscustomobject]@{
Zone = $_.Name
DecisionCount = $n
MedianMin = $median
P95Min = $p95
Breaches = $breachCount
BreachRatePct = if ($n -gt 0) { [math]::Round(100 * $breachCount / $n, 2) } else { 0 }
Threshold = $SlaMinutesByZone[$_.Name]
}
}
$anyBreach = ($rows | Where-Object Breached).Count -gt 0
$status = if ($anyBreach) { 'Anomaly' } else { 'Clean' }
$reason = if ($anyBreach) { "SLA breaches present — open incidents under Control 3.4." } else { '' }
$report = [pscustomobject]@{
RunId = $Session.RunId
Window = @{ Start=$Start.ToString('o'); End=$End.ToString('o') }
SlaMinutes = $SlaMinutesByZone
ByZone = $byZone
Decisions = $rows
Status = $status
Reason = $reason
GeneratedAt = (Get-Date).ToUniversalTime().ToString('o')
}
$report | ConvertTo-Json -Depth 8 | Out-File -FilePath $OutputPath -Encoding utf8
$report
}
4.4 Get-Sup212PowerAutomateApprovalHistory
Power Automate approval history is the authoritative record for review decisions raised through approval actions. Tenant-wide reads require -AdminMode on Get-AdminFlowApproval; without it, approvals initiated under service-principal owners are invisible (defect #0.7).
function Get-Sup212PowerAutomateApprovalHistory {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [datetime] $Start,
[Parameter(Mandatory)] [datetime] $End
)
if ($Session.Cloud.Sovereign) {
return [pscustomobject]@{
Status='NotApplicable'; Reason='SovereignRedirectActive'; RedirectTo='Sup212Sovereign'; Rows=@()
}
}
if (-not $Session.PowerPlatformConnected) {
return [pscustomobject]@{
Status='Error'; Reason='Sup212-NoPowerPlatform: Initialize-Sup212Session with -ConnectPowerPlatform.'; Rows=@()
}
}
$envs = Invoke-Sup212Throttled { Get-AdminPowerAppEnvironment }
$all = [System.Collections.Generic.List[object]]::new()
$denied = [System.Collections.Generic.List[string]]::new()
foreach ($env in $envs) {
try {
$apps = Invoke-Sup212Throttled {
Get-AdminFlowApproval -EnvironmentName $env.EnvironmentName -AdminMode:$true
}
foreach ($a in $apps) {
if ($a.CreatedTime -ge $Start -and $a.CreatedTime -le $End) {
$all.Add([pscustomobject]@{
EnvironmentId = $env.EnvironmentName
ApprovalId = $a.ApprovalId
Title = $a.Title
AgentId = $a.Properties.agentId
AgentZone = $a.Properties.agentZone
CreatedAtUtc = $a.CreatedTime.ToUniversalTime()
ResolvedAtUtc = if ($a.CompletedTime) { $a.CompletedTime.ToUniversalTime() } else { $null }
ReviewerUpn = $a.Response.Responder.UserPrincipalName
Decision = $a.Response.Response
Rationale = $a.Response.Comments
})
}
}
} catch {
if ($_.Exception.Message -match '403|Forbidden') {
$denied.Add($env.EnvironmentName)
} else {
throw
}
}
}
$status = if ($denied.Count -gt 0) { 'Anomaly' } elseif ($all.Count -eq 0) { 'NotApplicable' } else { 'Clean' }
$reason = if ($denied.Count -gt 0) { "AccessDenied on $($denied.Count) environment(s): $($denied -join ',')" }
elseif ($all.Count -eq 0) { "No approvals in window." }
else { '' }
[pscustomobject]@{
Status = $status
Reason = $reason
Rows = $all
EnvironmentsDenied = $denied
}
}
4.5 Export-Sup212QueueHealthBundle
The signed JSON bundle combines the configuration-export artifact (criterion #2) and the SLA report (criterion #4) into a single evidence bundle with a SHA-256 manifest.
function Export-Sup212QueueHealthBundle {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $ConfigRows,
[Parameter(Mandatory)] [object] $SlaReport,
[string] $BundleRoot = "./evidence/2.12/queue-health-$($Session.RunId)"
)
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit signed queue-health bundle')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$configPath = Join-Path $BundleRoot 'config-export.json'
$slaPath = Join-Path $BundleRoot 'sla-report.json'
$ConfigRows | ConvertTo-Json -Depth 6 | Out-File -FilePath $configPath -Encoding utf8
$SlaReport | ConvertTo-Json -Depth 8 | Out-File -FilePath $slaPath -Encoding utf8
$anomalies = @($ConfigRows | Where-Object { $_.Status -ne 'Clean' }).Count +
$(if ($SlaReport.Status -ne 'Clean') { 1 } else { 0 })
$status = if ($anomalies -gt 0) { 'Anomaly' } else { 'Clean' }
$reason = if ($anomalies -gt 0) { "$anomalies anomaly row(s) in bundle — see SLA breach and/or config defects." } else { '' }
$manifest = New-Sup212EvidenceManifest `
-Session $Session `
-Criterion 'queue-health' `
-Status $status -Reason $reason `
-Artifacts @($configPath, $slaPath) `
-Extras @{ criteria = @('#2', '#4') }
$manifestPath = Join-Path $BundleRoot 'manifest.json'
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath $manifestPath -Encoding utf8
$manifest
}
§5 — HITL Reviewer-Decision Audit Extraction (Verification Criterion #5)
Criterion #5 requires a random sample of N=25 reviewer decisions per quarter where every row has non-null ReviewerUpn, Timestamp, Decision ∈ {Approve, Reject, Escalate}, and Rationale, traceable to the original agent interaction and (for Agent Framework flows) to the originating request ID and checkpoint. This section automates extraction from the Purview unified audit log and the Entra directory-audit log, joins the two, runs non-null validation, and emits a signed quarterly extract.
The helpers in this section are read-only against audit surfaces. They do not mutate state. They require AuditLog.Read.All plus PIM-elevated Compliance Administrator for the unified audit read.
5.1 Ingestion-lag guard
Unified-audit-log rows for AI activity are subject to ingestion delays that can exceed 30 minutes. A query that ends in the past five minutes will routinely undercount. Every §5 helper enforces a lookback buffer — it refuses to run against an -End within the buffer unless the operator explicitly passes -AcceptIngestionLag, in which case the manifest records the acceptance.
function Assert-Sup212AuditIngestionBuffer {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [datetime] $End,
[int] $LookbackBufferMinutes = 45,
[switch] $AcceptIngestionLag
)
$now = (Get-Date).ToUniversalTime()
$threshold = $now.AddMinutes(-$LookbackBufferMinutes)
if ($End -gt $threshold -and -not $AcceptIngestionLag) {
throw [System.InvalidOperationException]::new(
"Sup212-IngestionLag: End ($End) is within $LookbackBufferMinutes-minute ingestion buffer. Pass -AcceptIngestionLag to override; the acceptance will be recorded in the evidence manifest.")
}
[pscustomobject]@{
Now = $now.ToString('o')
End = $End.ToString('o')
LookbackBufferMinutes = $LookbackBufferMinutes
AcceptedWithinBuffer = [bool]$AcceptIngestionLag
}
}
5.2 Get-Sup212ReviewerDecisionAudit
This helper pulls AI-activity rows from the Purview unified audit log and joins them to the Entra directory-audit log to resolve reviewer UPN where the audit row has only an object ID. It is the canonical source for criterion #5.
function Get-Sup212ReviewerDecisionAudit {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [datetime] $Start,
[Parameter(Mandatory)] [datetime] $End,
[int] $LookbackBufferMinutes = 45,
[switch] $AcceptIngestionLag,
[ValidateSet('UnifiedAudit','PowerPlatform','Both')]
[string] $Source = 'Both'
)
$buffer = Assert-Sup212AuditIngestionBuffer -End $End `
-LookbackBufferMinutes $LookbackBufferMinutes `
-AcceptIngestionLag:$AcceptIngestionLag
if (-not $Session.ExchangeConnected -and $Source -ne 'PowerPlatform') {
return [pscustomobject]@{
Status='Error'; Reason='Sup212-NoExo: Initialize-Sup212Session with -ConnectExchange for unified audit reads.'; Rows=@()
}
}
$rows = [System.Collections.Generic.List[object]]::new()
if ($Source -in 'UnifiedAudit','Both') {
$ua = Invoke-Sup212Throttled {
Search-UnifiedAuditLog -StartDate $Start -EndDate $End `
-RecordType AI -ResultSize 5000 -SessionCommand ReturnLargeSet
}
foreach ($row in $ua) {
$d = $row.AuditData | ConvertFrom-Json -ErrorAction SilentlyContinue
if (-not $d) { continue }
if ($d.Operation -notmatch 'Review|Approve|Reject|Escalate|Handoff|ApprovalResponse') { continue }
$rows.Add([pscustomobject]@{
Source = 'UnifiedAudit'
RecordId = $row.Identity
TimestampUtc = ([datetime]$row.CreationDate).ToUniversalTime()
Operation = $d.Operation
ReviewerId = $d.UserId
ReviewerUpn = $d.UserPrincipalName
AgentId = $d.AgentId
ConversationId= $d.ConversationId
RequestId = $d.RequestId
CheckpointId = $d.CheckpointId
Decision = $d.Decision
Rationale = $d.Justification
})
}
}
if ($Source -in 'PowerPlatform','Both') {
$pp = Get-Sup212PowerAutomateApprovalHistory -Session $Session -Start $Start -End $End
if ($pp.Status -eq 'Clean' -or $pp.Status -eq 'NotApplicable') {
foreach ($a in $pp.Rows) {
if (-not $a.ResolvedAtUtc) { continue }
$rows.Add([pscustomobject]@{
Source = 'PowerAutomate'
RecordId = $a.ApprovalId
TimestampUtc = $a.ResolvedAtUtc
Operation = 'ApprovalResponse'
ReviewerId = $null
ReviewerUpn = $a.ReviewerUpn
AgentId = $a.AgentId
ConversationId = $null
RequestId = $null
CheckpointId = $null
Decision = $a.Decision
Rationale = $a.Rationale
})
}
}
}
# Resolve UPN where only object ID is present
$needsResolve = $rows | Where-Object { -not $_.ReviewerUpn -and $_.ReviewerId }
foreach ($r in $needsResolve) {
try {
$u = Invoke-Sup212Throttled { Get-MgUser -UserId $r.ReviewerId -Property UserPrincipalName }
$r.ReviewerUpn = $u.UserPrincipalName
} catch {
# Leave UPN null; will be flagged in validation
}
}
# Non-null validation — emit Anomaly rows (never Clean) for null UPN / null Decision / null Rationale
$validated = $rows | ForEach-Object {
$missing = @()
if (-not $_.ReviewerUpn) { $missing += 'ReviewerUpn' }
if (-not $_.Decision) { $missing += 'Decision' }
if (-not $_.Rationale) { $missing += 'Rationale' }
if (-not $_.TimestampUtc){ $missing += 'TimestampUtc' }
$_ | Add-Member -NotePropertyName NonNullValidation `
-NotePropertyValue (if ($missing) { 'Anomaly' } else { 'Clean' }) -PassThru |
Add-Member -NotePropertyName MissingFields `
-NotePropertyValue $missing -PassThru
}
$anomalies = ($validated | Where-Object NonNullValidation -eq 'Anomaly').Count
$status = if ($validated.Count -eq 0) { 'NotApplicable' }
elseif ($anomalies -gt 0) { 'Anomaly' }
else { 'Clean' }
$reason = if ($validated.Count -eq 0) { 'No reviewer decisions in window.' }
elseif ($anomalies -gt 0) { "$anomalies of $($validated.Count) row(s) failed non-null validation." }
else { '' }
[pscustomobject]@{
Status = $status
Reason = $reason
Rows = $validated
Window = @{ Start=$Start.ToString('o'); End=$End.ToString('o') }
IngestionBuffer = $buffer
TotalRows = $validated.Count
AnomalyRows = $anomalies
}
}
5.3 Export-Sup212QuarterlyDecisionSample
Pulls a random sample of N=25 decisions per quarter (criterion #5 sample size), signs and hashes the sample, and emits the evidence bundle. Sampling uses a cryptographic RNG seed recorded in the manifest so that the sample is deterministically reproducible from the seed + full population.
function Export-Sup212QuarterlyDecisionSample {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [datetime] $Start,
[Parameter(Mandatory)] [datetime] $End,
[int] $SampleSize = 25,
[string] $BundleRoot = "./evidence/2.12/reviewer-decisions-$($Session.RunId)"
)
$decisions = Get-Sup212ReviewerDecisionAudit -Session $Session -Start $Start -End $End
if ($decisions.Status -eq 'Error') { return $decisions }
if ($decisions.Rows.Count -lt $SampleSize -and $decisions.Rows.Count -gt 0) {
Write-Warning "Sup212: population ($($decisions.Rows.Count)) smaller than sample size ($SampleSize); using full population."
$SampleSize = $decisions.Rows.Count
}
$sampleResult = Get-Sup212SamplePopulation -Population $decisions.Rows -SampleSize $SampleSize
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit signed quarterly decision sample')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$populationPath = Join-Path $BundleRoot 'population.json'
$samplePath = Join-Path $BundleRoot 'sample.json'
$decisions.Rows | ConvertTo-Json -Depth 6 | Out-File -FilePath $populationPath -Encoding utf8
$sampleResult.Sample | ConvertTo-Json -Depth 6 | Out-File -FilePath $samplePath -Encoding utf8
$manifest = New-Sup212EvidenceManifest `
-Session $Session `
-Criterion 'reviewer-decision-audit' `
-Status $decisions.Status -Reason $decisions.Reason `
-Artifacts @($populationPath, $samplePath) `
-Extras @{
criterion_number = '#5'
sample_size = $SampleSize
population_size = $decisions.Rows.Count
anomaly_rows = $decisions.AnomalyRows
sample_seed_b64 = $sampleResult.SeedBase64
window = $decisions.Window
ingestion_buffer = $decisions.IngestionBuffer
}
$manifestPath = Join-Path $BundleRoot 'manifest.json'
$manifest | ConvertTo-Json -Depth 8 | Out-File -FilePath $manifestPath -Encoding utf8
$manifest
}
§6 — Agent Framework Evidence Export (Verification Criterion #9)
Microsoft Agent Framework's human-in-the-loop pattern uses RequestPort / request_info() to pause an executor, preserve state in a checkpoint, emit a request to an external supervisor, and resume the workflow with the reviewer's response payload. For FINRA Rule 3110 purposes, the evidence unit is the tuple (request ID, checkpoint state at pause, reviewer response payload, final executor output), retained for six years per Control 2.13.
This section produces an idempotent export of that tuple. Idempotency is required because the export feeds a WORM store; re-running the export must not create duplicate entries, must not overwrite previously signed records, and must detect when an existing record was tampered with by comparing content hashes.
6.1 Get-Sup212AgentFrameworkConfig
Reads the endpoint configuration from sup212.config.json and validates it. Missing / placeholder fields return NotApplicable — not Clean — so the orchestrator in §14 refuses to mark criterion #9 as satisfied when the Agent Framework path is unwired.
function Get-Sup212AgentFrameworkConfig {
[CmdletBinding()]
param(
[string] $ConfigPath = './sup212.config.json'
)
if (-not (Test-Path $ConfigPath)) {
return [pscustomobject]@{ Status='NotApplicable'; Reason="Config missing at $ConfigPath."; Config=$null }
}
$c = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$missing = @()
foreach ($f in 'agentFrameworkEvidenceEndpoint','agentFrameworkClientId','agentFrameworkCertThumbprint') {
if (-not $c.$f) { $missing += $f }
}
if ($missing) {
return [pscustomobject]@{
Status='NotApplicable'
Reason="AgentFrameworkUnwired: missing fields in sup212.config.json — $($missing -join ',')"
Config=$c
}
}
[pscustomobject]@{ Status='Clean'; Reason=''; Config=$c }
}
6.2 Get-Sup212AgentFrameworkHitlConfig
Per-flow helper that verifies the HITL pattern is wired: every production workflow in scope has at least one RequestPort node, a response handler, and checkpointing enabled. Missing wiring → Anomaly, not Clean.
function Get-Sup212AgentFrameworkHitlConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object] $AgfConfig,
[Parameter(Mandatory)] [string[]] $WorkflowIds
)
$cfg = $AgfConfig.Config
$results = foreach ($wfId in $WorkflowIds) {
try {
$def = Invoke-Sup212Throttled {
Invoke-RestMethod -Method GET `
-Uri ("$($cfg.agentFrameworkEvidenceEndpoint.TrimEnd('/'))/workflows/$wfId") `
-Authentication Certificate `
-CertificateThumbprint $cfg.agentFrameworkCertThumbprint
}
$hasRequestPort = [bool]($def.nodes | Where-Object Type -eq 'RequestPort')
$hasResponseHandler = [bool]($def.nodes | Where-Object Type -eq 'ResponseHandler')
$hasCheckpointing = [bool]$def.checkpointing.enabled
$status = if ($hasRequestPort -and $hasResponseHandler -and $hasCheckpointing) { 'Clean' } else { 'Anomaly' }
$reason = if ($status -eq 'Clean') { '' } else {
$gaps = @()
if (-not $hasRequestPort) { $gaps += 'NoRequestPort' }
if (-not $hasResponseHandler) { $gaps += 'NoResponseHandler' }
if (-not $hasCheckpointing) { $gaps += 'CheckpointingDisabled' }
"HITL wiring gaps: $($gaps -join ',')"
}
[pscustomobject]@{
WorkflowId = $wfId
HasRequestPort = $hasRequestPort
HasResponseHandler = $hasResponseHandler
HasCheckpointing = $hasCheckpointing
Status = $status
Reason = $reason
}
} catch {
[pscustomobject]@{
WorkflowId=$wfId; HasRequestPort=$null; HasResponseHandler=$null; HasCheckpointing=$null
Status='Error'; Reason=$_.Exception.Message
}
}
}
$results
}
6.3 Export-Sup212AgentFrameworkEvidence (idempotent)
Pulls the (requestId, checkpointState, responsePayload, finalOutput) tuple for every HITL request resolved in the window, compares each tuple against any previously exported record under the same requestId, and writes only new records — plus an integrity report flagging any tuple whose hash disagrees with a previously exported record (which indicates either export-pipeline corruption or tampering in the source system). Both cases emit Anomaly and open an incident under Control 3.4.
function Export-Sup212AgentFrameworkEvidence {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [datetime] $Start,
[Parameter(Mandatory)] [datetime] $End,
[Parameter(Mandatory)] [object] $AgfConfig,
[string] $BundleRoot = "./evidence/2.12/agf-hitl-$($Session.RunId)",
[string] $PriorExportIndex = './evidence/2.12/agf-index.json'
)
if ($AgfConfig.Status -ne 'Clean') {
return [pscustomobject]@{
Status = $AgfConfig.Status
Reason = "AgentFrameworkConfig: $($AgfConfig.Reason)"
}
}
$cfg = $AgfConfig.Config
$requests = Invoke-Sup212Throttled {
Invoke-RestMethod -Method GET `
-Uri ("$($cfg.agentFrameworkEvidenceEndpoint.TrimEnd('/'))/requests?start=$($Start.ToString('o'))&end=$($End.ToString('o'))") `
-Authentication Certificate `
-CertificateThumbprint $cfg.agentFrameworkCertThumbprint
}
# Cross-check: if run activity is non-zero but request count is zero, flag as Anomaly (defect #0.9)
$runActivity = Invoke-Sup212Throttled {
Invoke-RestMethod -Method GET `
-Uri ("$($cfg.agentFrameworkEvidenceEndpoint.TrimEnd('/'))/runs/count?start=$($Start.ToString('o'))&end=$($End.ToString('o'))") `
-Authentication Certificate `
-CertificateThumbprint $cfg.agentFrameworkCertThumbprint
}
if (($requests.Count -eq 0) -and ($runActivity.count -gt 0)) {
return [pscustomobject]@{
Status = 'Anomaly'
Reason = "Sup212-AgfCorrelationMismatch: zero HITL requests but $($runActivity.count) runs in window. Confirm RequestPort wiring is present (see §6.2)."
}
}
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit signed Agent Framework evidence bundle')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$index = if (Test-Path $PriorExportIndex) { Get-Content $PriorExportIndex -Raw | ConvertFrom-Json -AsHashtable } else { @{} }
$newCount = 0
$tamperCount = 0
$tamperRows = [System.Collections.Generic.List[object]]::new()
foreach ($r in $requests) {
$tuple = [ordered]@{
requestId = $r.requestId
workflowId = $r.workflowId
pausedAtUtc = $r.pausedAtUtc
resumedAtUtc = $r.resumedAtUtc
checkpointState = $r.checkpointState
reviewerResponse = $r.responsePayload
finalOutput = $r.finalOutput
reviewerUpn = $r.reviewerUpn
decision = $r.decision
rationale = $r.rationale
}
$json = ($tuple | ConvertTo-Json -Depth 10 -Compress)
$hash = [BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($json))
).Replace('-', '').ToLowerInvariant()
if ($index.ContainsKey($r.requestId)) {
if ($index[$r.requestId].sha256 -ne $hash) {
$tamperCount++
$tamperRows.Add([pscustomobject]@{
RequestId = $r.requestId
PriorSha256 = $index[$r.requestId].sha256
CurrentSha256 = $hash
PriorExportRef = $index[$r.requestId].exportPath
})
}
continue # idempotent: skip already-exported
}
$exportPath = Join-Path $BundleRoot "$($r.requestId).json"
$json | Out-File -FilePath $exportPath -Encoding utf8 -NoNewline
$index[$r.requestId] = @{ sha256=$hash; exportPath=$exportPath; exportedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
$newCount++
}
$index | ConvertTo-Json -Depth 6 | Out-File -FilePath $PriorExportIndex -Encoding utf8
$tamperPath = Join-Path $BundleRoot 'integrity-report.json'
$tamperRows | ConvertTo-Json -Depth 4 | Out-File -FilePath $tamperPath -Encoding utf8
$status = if ($tamperCount -gt 0) { 'Anomaly' } else { 'Clean' }
$reason = if ($tamperCount -gt 0) { "Integrity mismatch on $tamperCount request(s) — open Control 3.4 incident." } else { '' }
$manifest = New-Sup212EvidenceManifest `
-Session $Session `
-Criterion 'agent-framework-evidence' `
-Status $status -Reason $reason `
-Artifacts @($tamperPath, $PriorExportIndex) `
-Extras @{
criterion_number = '#9'
requests_in_window = $requests.Count
runs_in_window = $runActivity.count
new_records = $newCount
tamper_count = $tamperCount
}
$manifestPath = Join-Path $BundleRoot 'manifest.json'
$manifest | ConvertTo-Json -Depth 8 | Out-File -FilePath $manifestPath -Encoding utf8
$manifest
}
§7 — Principal Registration Verification (Verification Criterion #3)
Criterion #3 requires that for 100% of designated principals listed in the WSP addendum, the firm can produce current CRD verification (Series 24 for broker-dealer supervisory scope; Series 66 / 65 for RIA supervisory scope) dated within the last 90 days. CRD (Central Registration Depository) and WebCRD are FINRA-operated systems; programmatic access is not available to all firms, and the PowerShell helpers here cannot reach FINRA Gateway directly. The helper pattern below supports both automated extraction (where the firm has contracted CRD data-feed access) and the documented manual process (CRD printout / WebCRD attestation loaded from a secured staging location).
7.1 Test-Sup212PrincipalRegistration
function Test-Sup212PrincipalRegistration {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $Principals, # @(@{ Upn=..; FullName=..; CrdNumber=..; RequiredExam=..; Scope=..; })
[ValidateSet('CrdFeed','ManualAttestation')]
[string] $Mode = 'ManualAttestation',
[string] $AttestationStoreRoot = './evidence/2.12/principal-registrations',
[int] $FreshnessDays = 90
)
$results = foreach ($p in $Principals) {
$now = (Get-Date).ToUniversalTime()
$freshnessCutoff = $now.AddDays(-$FreshnessDays)
switch ($Mode) {
'CrdFeed' {
# Placeholder: firms with contracted CRD data feeds replace this with the vendor client call.
# The helper returns NotApplicable (not Clean) if the feed is not wired — defect #0.10.
[pscustomobject]@{
Upn = $p.Upn
FullName = $p.FullName
CrdNumber = $p.CrdNumber
RequiredExam = $p.RequiredExam
Scope = $p.Scope
Status = 'NotApplicable'
Reason = 'CrdAccessNotAvailable: wire the vendor-specific CRD feed client, or use -Mode ManualAttestation.'
}
}
'ManualAttestation' {
$attPath = Join-Path $AttestationStoreRoot "$($p.CrdNumber).json"
if (-not (Test-Path $attPath)) {
[pscustomobject]@{
Upn=$p.Upn; FullName=$p.FullName; CrdNumber=$p.CrdNumber
RequiredExam=$p.RequiredExam; Scope=$p.Scope
Status='Anomaly'
Reason="ManualAttestationMissing at $attPath. Load the CRD/WebCRD extract through the documented manual process."
}
continue
}
$a = Get-Content $attPath -Raw | ConvertFrom-Json
$asOf = [datetime]$a.asOfUtc
$examOk = $a.currentExams -contains $p.RequiredExam
$freshOk = $asOf -ge $freshnessCutoff
$signerOk = [bool]$a.signerUpn
$status = if ($examOk -and $freshOk -and $signerOk) { 'Clean' } else { 'Anomaly' }
$r = @()
if (-not $examOk) { $r += "RequiredExam $($p.RequiredExam) not in attestation." }
if (-not $freshOk) { $r += "AttestationStale: asOf=$($asOf.ToString('o')); cutoff=$($freshnessCutoff.ToString('o'))." }
if (-not $signerOk) { $r += 'SignerUpnMissing on attestation.' }
[pscustomobject]@{
Upn = $p.Upn
FullName = $p.FullName
CrdNumber = $p.CrdNumber
RequiredExam = $p.RequiredExam
Scope = $p.Scope
AsOfUtc = $a.asOfUtc
CurrentExams = $a.currentExams
SignerUpn = $a.signerUpn
Status = $status
Reason = ($r -join ' ')
}
}
}
}
$results
}
7.2 Export-Sup212PrincipalAttestationBundle
Rolls the per-principal results into a signed quarterly attestation record and emits evidence via the manifest helper.
function Export-Sup212PrincipalAttestationBundle {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $PrincipalResults,
[string] $BundleRoot = "./evidence/2.12/principal-registration-$($Session.RunId)"
)
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit principal-attestation bundle')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$p = Join-Path $BundleRoot 'principals.json'
$PrincipalResults | ConvertTo-Json -Depth 6 | Out-File -FilePath $p -Encoding utf8
$anomalies = ($PrincipalResults | Where-Object Status -eq 'Anomaly').Count
$status = if ($anomalies -gt 0) { 'Anomaly' } else { 'Clean' }
$reason = if ($anomalies -gt 0) { "$anomalies principal(s) failed registration check." } else { '' }
$manifest = New-Sup212EvidenceManifest `
-Session $Session -Criterion 'principal-registration' `
-Status $status -Reason $reason -Artifacts @($p) `
-Extras @{ criterion_number='#3'; principal_count=$PrincipalResults.Count; anomaly_count=$anomalies }
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath (Join-Path $BundleRoot 'manifest.json') -Encoding utf8
$manifest
}
§8 — Sampling Protocol Runner
Criterion #7 (Rule 2210) and the zone-specific sampling rates in the control document require a reproducible random sample from the agent output pool. This section provides a cryptographically-seeded sampler, a supervision-register writer that records disposition, and a disposition tracker that reconciles sampled items against reviewer decisions.
8.1 Get-Sup212SamplePopulation
function Get-Sup212SamplePopulation {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object[]] $Population,
[Parameter(Mandatory)] [int] $SampleSize,
[byte[]] $SeedBytes # optional — for replay
)
if ($SampleSize -lt 0) {
throw "Sup212-BadSampleSize: $SampleSize"
}
if ($Population.Count -eq 0) {
return [pscustomobject]@{
Status='NotApplicable'; Reason='Empty population.'; Sample=@(); SeedBase64=$null
}
}
if ($SampleSize -ge $Population.Count) {
return [pscustomobject]@{
Status='Clean'; Reason='SampleSizeGePopulation — returning full population.'; Sample=$Population; SeedBase64=$null
}
}
if (-not $SeedBytes) {
$SeedBytes = [byte[]]::new(32)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($SeedBytes)
}
$seedB64 = [Convert]::ToBase64String($SeedBytes)
# Deterministic Fisher-Yates driven by SHA-256 of seed + index
$n = $Population.Count
$indices = 0..($n - 1)
for ($i = $n - 1; $i -gt 0; $i--) {
$buf = [System.Text.Encoding]::UTF8.GetBytes("$seedB64|$i")
$hash = [System.Security.Cryptography.SHA256]::HashData($buf)
$j = [bitconverter]::ToUInt32($hash, 0) % ($i + 1)
$tmp = $indices[$i]; $indices[$i] = $indices[$j]; $indices[$j] = $tmp
}
$sample = $indices[0..($SampleSize - 1)] | ForEach-Object { $Population[$_] }
[pscustomobject]@{
Status = 'Clean'
Reason = ''
Sample = $sample
SeedBase64 = $seedB64
Algorithm = 'FisherYates-SHA256'
SampleSize = $SampleSize
Population = $Population.Count
}
}
8.2 Write-Sup212SupervisionRegister
Appends each sampled item to the supervision register (a SharePoint list or CSV). Each entry carries the sampling seed, sample index, and initial disposition Pending; reviewers later update disposition through the §4 / §5 review paths, and Test-Sup212SamplingDisposition reconciles them.
function Write-Sup212SupervisionRegister {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $SampleRows,
[Parameter(Mandatory)] [string] $SeedBase64,
[string] $RegisterCsvPath = './evidence/2.12/supervision-register.csv'
)
if (-not $PSCmdlet.ShouldProcess($RegisterCsvPath, 'Append supervision register rows')) { return }
$dir = Split-Path -Parent $RegisterCsvPath
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$rows = $SampleRows | ForEach-Object {
[pscustomobject]@{
RunId = $Session.RunId
SampledAtUtc = (Get-Date).ToUniversalTime().ToString('o')
SampleSeedB64 = $SeedBase64
AgentId = $_.AgentId
ConversationId = $_.ConversationId
RequestId = $_.RequestId
Zone = $_.AgentZone
Disposition = 'Pending'
ReviewerUpn = ''
DecisionAtUtc = ''
Decision = ''
Rationale = ''
}
}
if (Test-Path $RegisterCsvPath) {
$rows | Export-Csv -Path $RegisterCsvPath -Append -NoTypeInformation -Encoding utf8
} else {
$rows | Export-Csv -Path $RegisterCsvPath -NoTypeInformation -Encoding utf8
}
[pscustomobject]@{ Status='Clean'; Reason=''; Appended=$rows.Count; Path=$RegisterCsvPath }
}
8.3 Test-Sup212SamplingDisposition
Reconciles pending register rows against reviewer decisions; rows still Pending beyond the zone SLA window are flagged as Anomaly.
function Test-Sup212SamplingDisposition {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $RegisterCsvPath,
[Parameter(Mandatory)] [hashtable] $SlaMinutesByZone
)
if (-not (Test-Path $RegisterCsvPath)) {
return [pscustomobject]@{ Status='Error'; Reason='RegisterMissing' }
}
$reg = Import-Csv $RegisterCsvPath
$now = (Get-Date).ToUniversalTime()
$pending = foreach ($r in ($reg | Where-Object Disposition -eq 'Pending')) {
$age = ($now - ([datetime]$r.SampledAtUtc).ToUniversalTime()).TotalMinutes
$threshold = $SlaMinutesByZone[$r.Zone]
[pscustomobject]@{
AgentId=$r.AgentId; Zone=$r.Zone; AgeMin=[math]::Round($age,1); Threshold=$threshold
Breached = ($threshold -and $age -gt $threshold)
}
}
$breaches = ($pending | Where-Object Breached).Count
[pscustomobject]@{
Status = if ($breaches -gt 0) { 'Anomaly' } else { 'Clean' }
Reason = if ($breaches -gt 0) { "$breaches pending sample(s) past zone SLA." } else { '' }
Pending = $pending
}
}
§9 — Rule 3120 Annual-Testing Harness (Verification Criterion #6)
FINRA Rule 3120 requires annual testing of the firm's supervisory controls with documented design effectiveness, operating effectiveness, exceptions, and remediation. This section packages the tests as Pester 5 suites organized into named namespaces. Each namespace tests one area of the control; the harness refuses to mark results Clean when any test was Skipped (defect #0.12).
9.1 Namespace map
| Namespace | Area | Typical It count |
|---|---|---|
WSP |
WSP addendum coverage (criterion #1) | 4–6 |
HITL |
HITL trigger firing (criterion #2) | 6–10 |
QUEUE |
Review queue SLA adherence (criterion #4) | 4–6 |
REVIEWER |
Reviewer-decision non-null validation (criterion #5) | 3–5 |
PRINCIPAL |
Principal registration freshness (criterion #3) | 2–4 |
SAMPLING |
Sampling-protocol reproducibility and rate (criterion #7 support) | 3–5 |
R3120 |
Self-test: does the harness itself run? | 1–2 |
R2210 |
Communication-classification coverage (criterion #7) | 4–6 |
SPONSOR |
Sponsor attestation quarterly-review freshness (criterion #8 support) | 2–4 |
AGF |
Agent Framework evidence integrity (criterion #9) | 3–5 |
SOV |
Sovereign-cloud compensating-control operation (criterion #8) | 2–4 |
SIEM |
SIEM forwarding canary (criterion support for 1.7/3.4 linkage) | 2–3 |
9.2 Invoke-Sup212Rule3120Harness
function Invoke-Sup212Rule3120Harness {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[string] $TestRoot = './tests/2.12',
[string] $BundleRoot = "./evidence/2.12/rule-3120-$($Session.RunId)",
[string[]] $Namespace = @('WSP','HITL','QUEUE','REVIEWER','PRINCIPAL','SAMPLING','R3120','R2210','SPONSOR','AGF','SOV','SIEM')
)
$null = Assert-Sup212ShellHost -RequirePester
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Run Rule 3120 Pester harness')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$cfg = New-PesterConfiguration
$cfg.Run.Path = $TestRoot
$cfg.Run.PassThru = $true
$cfg.Filter.Tag = $Namespace
$cfg.Output.Verbosity = 'Detailed'
$cfg.TestResult.Enabled = $true
$cfg.TestResult.OutputFormat = 'NUnitXml'
$cfg.TestResult.OutputPath = (Join-Path $BundleRoot 'pester-results.xml')
$result = Invoke-Pester -Configuration $cfg
$status = if ($result.FailedCount -gt 0) { 'Anomaly' }
elseif ($result.SkippedCount -gt 0) { 'Anomaly' }
elseif ($result.TotalCount -eq 0) { 'NotApplicable' }
else { 'Clean' }
$reason = if ($result.FailedCount -gt 0) { "$($result.FailedCount) test(s) failed." }
elseif ($result.SkippedCount -gt 0) { "$($result.SkippedCount) test(s) skipped — harness refuses Clean when skips are present." }
elseif ($result.TotalCount -eq 0) { 'No tests discovered — confirm $TestRoot is populated.' }
else { '' }
$summary = [pscustomobject]@{
Passed = $result.PassedCount
Failed = $result.FailedCount
Skipped = $result.SkippedCount
Total = $result.TotalCount
Duration = $result.Duration.ToString()
Namespaces = $Namespace
Status = $status
Reason = $reason
}
$summary | ConvertTo-Json -Depth 4 | Out-File -FilePath (Join-Path $BundleRoot 'summary.json') -Encoding utf8
$manifest = New-Sup212EvidenceManifest `
-Session $Session -Criterion 'rule-3120-annual-test' `
-Status $status -Reason $reason `
-Artifacts @((Join-Path $BundleRoot 'pester-results.xml'), (Join-Path $BundleRoot 'summary.json')) `
-Extras @{ criterion_number='#6'; passed=$result.PassedCount; failed=$result.FailedCount; skipped=$result.SkippedCount }
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath (Join-Path $BundleRoot 'manifest.json') -Encoding utf8
$manifest
}
9.3 Example Pester files (excerpt)
Keep each namespace in its own .Tests.ps1 file under ./tests/2.12/.
WSP.Tests.ps1:
Describe 'WSP addendum coverage' -Tag 'WSP' {
BeforeAll {
$wsp = Get-Content './evidence/2.12/wsp-addendum.metadata.json' -Raw | ConvertFrom-Json
}
It 'names Control 2.12 explicitly' {
$wsp.controlsReferenced | Should -Contain '2.12'
}
It 'enumerates supervision activities for every zone' {
$wsp.zonesCovered | Sort-Object | Should -Be @('1','2','3')
}
It 'designates principals by name and registration' {
($wsp.designatedPrincipals | Measure-Object).Count | Should -BeGreaterThan 0
$wsp.designatedPrincipals | ForEach-Object { $_.crdNumber | Should -Not -BeNullOrEmpty }
}
It 'was approved by a registered principal within the last 12 months' {
([datetime]::UtcNow - [datetime]$wsp.approvedAtUtc).TotalDays | Should -BeLessThan 366
$wsp.approverCrdNumber | Should -Not -BeNullOrEmpty
}
}
HITL.Tests.ps1:
Describe 'HITL trigger firing' -Tag 'HITL' {
BeforeAll {
$cfg = Get-Content './evidence/2.12/queue-health/config-export.json' -Raw | ConvertFrom-Json
}
It 'every Zone 3 agent has at least one handoff or approval trigger' {
foreach ($row in $cfg) {
($row.HandoffTriggers -or $row.ApprovalTriggers) | Should -BeTrue -Because "Agent $($row.AgentName) must have HITL wiring."
}
}
It 'no Zone 3 agent is in Status=Error' {
($cfg | Where-Object Status -eq 'Error') | Should -BeNullOrEmpty
}
}
R2210.Tests.ps1 and the remaining namespaces follow the same shape — each asserts the presence and integrity of an evidence artifact produced by the §3–§13 helpers, never the absence of an error.
§10 — Rule 2210 Classification Pipeline (Verification Criterion #7)
FINRA Rule 2210 distinguishes Correspondence (≤25 retail investors in any 30 calendar-day period), Retail Communication (>25 retail investors), and Institutional Communication (institutional investors only). Each bucket carries a different supervisory requirement; Retail Communication requires principal pre-use approval unless one of the 2210(b)(1) exclusions applies (e.g., previously-approved templated content). This section classifies customer-facing outputs, attaches pre-use-approval evidence for Retail Communications, and emits criterion #7 evidence.
10.1 Invoke-Sup212Rule2210Classifier
function Invoke-Sup212Rule2210Classifier {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $Outputs, # rows with { AgentId, OutputId, AudienceType, RetailCountIn30d, Body }
[Parameter(Mandatory)] [string] $PreApprovalCsvPath # rows with { TemplateId/OutputId, ApprovalId, ApproverCrdNumber, ApprovedAtUtc, ExclusionCitation }
)
if (-not (Test-Path $PreApprovalCsvPath)) {
return [pscustomobject]@{ Status='Error'; Reason="PreApprovalCsvMissing at $PreApprovalCsvPath." }
}
$approvals = Import-Csv $PreApprovalCsvPath
$rows = foreach ($o in $Outputs) {
$classification = switch ($o.AudienceType) {
'InstitutionalOnly' { 'Institutional' }
default {
if ([int]$o.RetailCountIn30d -le 25) { 'Correspondence' } else { 'Retail' }
}
}
$approval = $null
$exclusion = $null
$status = 'Clean'
$reason = ''
if ($classification -eq 'Retail') {
$approval = $approvals | Where-Object { $_.OutputId -eq $o.OutputId -or $_.TemplateId -eq $o.TemplateId } | Select-Object -First 1
if (-not $approval) {
$status = 'Anomaly'
$reason = 'RetailCommunicationWithoutPrincipalPreApproval — attach approval or document Rule 2210(b)(1) exclusion.'
} else {
if (-not $approval.ApproverCrdNumber) {
$status = 'Anomaly'; $reason = 'ApproverCrdNumberMissing on pre-approval row.'
}
if ($approval.ExclusionCitation) {
$exclusion = $approval.ExclusionCitation
}
}
}
[pscustomobject]@{
AgentId = $o.AgentId
OutputId = $o.OutputId
AudienceType = $o.AudienceType
RetailCount30d = [int]$o.RetailCountIn30d
Classification = $classification
ApprovalId = if ($approval) { $approval.ApprovalId } else { $null }
ApproverCrd = if ($approval) { $approval.ApproverCrdNumber } else { $null }
ExclusionCitation = $exclusion
Status = $status
Reason = $reason
}
}
$anomalies = ($rows | Where-Object Status -eq 'Anomaly').Count
[pscustomobject]@{
Status = if ($rows.Count -eq 0) { 'NotApplicable' } elseif ($anomalies -gt 0) { 'Anomaly' } else { 'Clean' }
Reason = if ($rows.Count -eq 0) { 'No outputs supplied.' } elseif ($anomalies -gt 0) { "$anomalies classification anomaly row(s)." } else { '' }
Rows = $rows
ClassificationSummary = $rows | Group-Object Classification | ForEach-Object {
[pscustomobject]@{ Type=$_.Name; Count=$_.Count }
}
}
}
10.2 Export-Sup212Rule2210Bundle
function Export-Sup212Rule2210Bundle {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object] $ClassificationResult,
[string] $BundleRoot = "./evidence/2.12/rule-2210-$($Session.RunId)"
)
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit Rule 2210 classification bundle')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$p = Join-Path $BundleRoot 'classifications.json'
$ClassificationResult | ConvertTo-Json -Depth 6 | Out-File -FilePath $p -Encoding utf8
$manifest = New-Sup212EvidenceManifest `
-Session $Session -Criterion 'rule-2210-classification' `
-Status $ClassificationResult.Status -Reason $ClassificationResult.Reason `
-Artifacts @($p) `
-Extras @{ criterion_number='#7'; summary=$ClassificationResult.ClassificationSummary }
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath (Join-Path $BundleRoot 'manifest.json') -Encoding utf8
$manifest
}
§11 — Sponsor Attestation Runner
The sponsorship model documented in Control 2.12 (Entra Agent ID) is operationalized through quarterly Entra access reviews targeting Zone 3 agents. This section pulls the review status, flags auto-approval (defect #0.13), and emits a signed quarterly attestation evidence bundle.
11.1 Get-Sup212SponsorAttestation
function Get-Sup212SponsorAttestation {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [string] $AccessReviewScheduleId
)
if ($Session.Cloud.Sovereign) {
return [pscustomobject]@{
Status='NotApplicable'; Reason='SovereignRedirectActive — Entra Agent ID sponsorship not GA in sovereign clouds.'; RedirectTo='Sup212Sovereign'
}
}
$schedule = Invoke-Sup212Throttled {
Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions/$AccessReviewScheduleId"
}
$instances = (Invoke-Sup212PagedQuery `
-Uri "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions/$AccessReviewScheduleId/instances").Rows
$defaultDecision = $schedule.settings.defaultDecision
$defaultDecisionEnabled = $schedule.settings.defaultDecisionEnabled
$autoApprovalAnomaly = ($defaultDecisionEnabled -eq $true -and $defaultDecision -eq 'Approve')
$perInstance = foreach ($i in $instances) {
$decisions = (Invoke-Sup212PagedQuery `
-Uri "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions/$AccessReviewScheduleId/instances/$($i.id)/decisions").Rows
$autoCount = ($decisions | Where-Object { $_.justification -match 'auto|Not reviewed|default' }).Count
[pscustomobject]@{
InstanceId = $i.id
Status = $i.status
StartDateTime = $i.startDateTime
EndDateTime = $i.endDateTime
DecisionCount = $decisions.Count
AutoDecisionCount = $autoCount
}
}
$status = if ($autoApprovalAnomaly) { 'Anomaly' } else { 'Clean' }
$reason = if ($autoApprovalAnomaly) { 'AccessReviewAutoApprovalEnabled — defaultDecisionEnabled=true and defaultDecision=Approve. Reconfigure before relying on reviews for FINRA 3110 evidence.' } else { '' }
[pscustomobject]@{
ScheduleId = $AccessReviewScheduleId
DefaultDecision = $defaultDecision
DefaultDecisionEnabled = $defaultDecisionEnabled
Instances = $perInstance
Status = $status
Reason = $reason
}
}
11.2 Export-Sup212SponsorAttestationBundle
function Export-Sup212SponsorAttestationBundle {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object] $AttestationResult,
[string] $BundleRoot = "./evidence/2.12/sponsor-attestation-$($Session.RunId)"
)
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit sponsor-attestation bundle')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$p = Join-Path $BundleRoot 'attestation.json'
$AttestationResult | ConvertTo-Json -Depth 6 | Out-File -FilePath $p -Encoding utf8
$manifest = New-Sup212EvidenceManifest `
-Session $Session -Criterion 'sponsor-attestation' `
-Status $AttestationResult.Status -Reason $AttestationResult.Reason `
-Artifacts @($p) -Extras @{ criterion_number='#8-support'; schedule_id=$AttestationResult.ScheduleId }
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath (Join-Path $BundleRoot 'manifest.json') -Encoding utf8
$manifest
}
§12 — Sovereign-Cloud Compensating-Control Runner (Verification Criterion #8)
Sovereign tenants (GCC / GCC High / DoD / China / Germany) cannot consume several of the Microsoft surfaces this control depends on (Copilot Studio handoff, Agent 365 admin, Entra Agent ID sponsorship) at April 2026. The compensating control is a quarterly principal-led manual supervisory review against the Control 1.2 / 3.1 agent registry, covering all Zone 3 agents, evidenced by a dual-signature attestation record. The runner below populates the manual review register, emits the attestation template for wet-or-digital signing, and — only once both signatures are present — emits the final signed bundle.
Baseline anchor: _shared/powershell-baseline.md#3-sovereign-cloud-endpoints-gcc-gcc-high-dod.
12.1 Invoke-Sup212SovereignRegister
function Invoke-Sup212SovereignRegister {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [string] $RegistryCsvPath, # Control 3.1 export
[Parameter(Mandatory)] [string] $PrincipalUpn,
[Parameter(Mandatory)] [string] $PrincipalCrdNumber,
[Parameter(Mandatory)] [string] $GovernanceLeadUpn,
[string] $BundleRoot = "./evidence/2.12/sovereign-compensating-$($Session.RunId)"
)
if (-not $Session.Cloud.Sovereign) {
return [pscustomobject]@{
Status='NotApplicable'; Reason='Not a sovereign tenant — run the commercial helpers in §4–§6.'
}
}
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Emit sovereign compensating-control register')) { return }
New-Item -ItemType Directory -Path $BundleRoot -Force | Out-Null
$zone3 = Import-Csv $RegistryCsvPath | Where-Object { $_.Zone -eq '3' -and $_.Status -eq 'Active' }
if (-not $zone3 -or $zone3.Count -eq 0) {
return [pscustomobject]@{
Status='NotApplicable'
Reason='No active Zone 3 agents in registry. Confirm with Agent Owner before signing the sovereign attestation.'
}
}
$register = foreach ($a in $zone3) {
[pscustomobject]@{
RunId = $Session.RunId
TenantCloud = $Session.Cloud.EnvName
AgentId = $a.AgentId
AgentName = $a.AgentName
OwnerUpn = $a.OwnerUpn
Zone = $a.Zone
SampledAtUtc = (Get-Date).ToUniversalTime().ToString('o')
ReviewedAtUtc = ''
PrincipalUpn = $PrincipalUpn
PrincipalCrd = $PrincipalCrdNumber
PrincipalSignature= ''
GovernanceLeadUpn = $GovernanceLeadUpn
GovernanceLeadSignature = ''
Disposition = 'PendingReview'
Rationale = ''
}
}
$regPath = Join-Path $BundleRoot 'manual-review-register.csv'
$register | Export-Csv -Path $regPath -NoTypeInformation -Encoding utf8
$template = [ordered]@{
control_id = '2.12'
tenant_cloud = $Session.Cloud.EnvName
run_id = $Session.RunId
quarter = "Q$([int](([datetime]::UtcNow.Month - 1) / 3 + 1))-$((Get-Date).Year)"
review_period_start = (Get-Date).AddMonths(-3).ToString('o')
review_period_end = (Get-Date).ToString('o')
zone3_agents_in_scope = $zone3.Count
principal_upn = $PrincipalUpn
principal_crd = $PrincipalCrdNumber
governance_lead_upn = $GovernanceLeadUpn
principal_signed_at = ''
principal_signature = ''
governance_signed_at = ''
governance_signature = ''
dual_signature_complete= $false
instructions = 'Populate manual-review-register.csv with per-agent disposition and rationale, then obtain principal and governance-lead signatures before running Complete-Sup212SovereignAttestation.'
}
$templatePath = Join-Path $BundleRoot 'attestation-template.json'
$template | ConvertTo-Json -Depth 4 | Out-File -FilePath $templatePath -Encoding utf8
$manifest = New-Sup212EvidenceManifest `
-Session $Session -Criterion 'sovereign-compensating' `
-Status 'Pending' -Reason 'AwaitingManualReviewAndDualSignature' `
-Artifacts @($regPath, $templatePath) `
-Extras @{ criterion_number='#8'; agents_in_scope=$zone3.Count; tenant_cloud=$Session.Cloud.EnvName }
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath (Join-Path $BundleRoot 'manifest.json') -Encoding utf8
$manifest
}
12.2 Complete-Sup212SovereignAttestation
function Complete-Sup212SovereignAttestation {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [string] $BundleRoot,
[Parameter(Mandatory)] [string] $PrincipalSignatureB64,
[Parameter(Mandatory)] [string] $GovernanceLeadSignatureB64
)
$templatePath = Join-Path $BundleRoot 'attestation-template.json'
$regPath = Join-Path $BundleRoot 'manual-review-register.csv'
if (-not (Test-Path $templatePath) -or -not (Test-Path $regPath)) {
return [pscustomobject]@{ Status='Error'; Reason='BundleIncomplete' }
}
$t = Get-Content $templatePath -Raw | ConvertFrom-Json
$reg = Import-Csv $regPath
$pending = ($reg | Where-Object Disposition -eq 'PendingReview').Count
if ($pending -gt 0) {
return [pscustomobject]@{
Status='Anomaly'; Reason="$pending register row(s) still PendingReview. Populate disposition before signing."
}
}
if (-not $PSCmdlet.ShouldProcess($BundleRoot, 'Sign sovereign attestation')) { return }
$t.principal_signed_at = (Get-Date).ToUniversalTime().ToString('o')
$t.principal_signature = $PrincipalSignatureB64
$t.governance_signed_at = (Get-Date).ToUniversalTime().ToString('o')
$t.governance_signature = $GovernanceLeadSignatureB64
$t.dual_signature_complete = $true
$t | ConvertTo-Json -Depth 4 | Out-File -FilePath $templatePath -Encoding utf8
$manifest = New-Sup212EvidenceManifest `
-Session $Session -Criterion 'sovereign-compensating' `
-Status 'Clean' -Reason '' `
-Artifacts @($templatePath, $regPath) `
-Extras @{ criterion_number='#8'; dual_signature_complete=$true }
$manifest | ConvertTo-Json -Depth 6 | Out-File -FilePath (Join-Path $BundleRoot 'manifest.json') -Encoding utf8
$manifest
}
§13 — SIEM Forwarding to Microsoft Sentinel
Supervision events — HITL decisions, escalations, SLA breaches, Rule 3120 test results, sovereign attestations — must be forwarded to the firm's SIEM for correlation with the broader controls in Pillar 1 — Audit Logging and incident operations in Control 3.4. This section ships events to Sentinel via the Logs Ingestion API with an integrity hash per batch, and verifies end-to-end plumbing with a canary.
13.1 Send-Sup212SentinelEvents
function Send-Sup212SentinelEvents {
[CmdletBinding(SupportsShouldProcess=$true)]
param(
[Parameter(Mandatory)] [object] $Session,
[Parameter(Mandatory)] [object[]] $Events,
[string] $ConfigPath = './sup212.config.json'
)
$c = Get-Content $ConfigPath -Raw | ConvertFrom-Json
foreach ($f in 'sentinelDataCollectionEndpointUri','sentinelDcrImmutableId','sentinelStreamName') {
if (-not $c.$f) {
return [pscustomobject]@{ Status='NotApplicable'; Reason="SentinelUnwired: $f missing in $ConfigPath." }
}
}
$token = (Get-AzAccessToken -ResourceUrl 'https://monitor.azure.com').Token
if (-not $token) {
return [pscustomobject]@{ Status='Error'; Reason='AzAccessTokenMissing — run Connect-AzAccount.' }
}
# Enrich every event with RunId, controlId, and a per-batch integrity hash
$batchId = [guid]::NewGuid().ToString()
$enriched = $Events | ForEach-Object {
[ordered]@{
TimeGenerated = (Get-Date).ToUniversalTime().ToString('o')
ControlId = '2.12'
RunId = $Session.RunId
BatchId = $batchId
TenantCloud = $Session.Cloud.EnvName
EventType = $_.EventType
Payload = $_.Payload
PayloadSha256 = [BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::HashData(
[System.Text.Encoding]::UTF8.GetBytes(($_.Payload | ConvertTo-Json -Depth 10 -Compress))
)
).Replace('-','').ToLowerInvariant()
}
}
if (-not $PSCmdlet.ShouldProcess($c.sentinelDataCollectionEndpointUri, "Forward $($enriched.Count) event(s)")) { return }
$body = $enriched | ConvertTo-Json -Depth 10 -AsArray
$uri = "$($c.sentinelDataCollectionEndpointUri.TrimEnd('/'))/dataCollectionRules/$($c.sentinelDcrImmutableId)/streams/$($c.sentinelStreamName)?api-version=2023-01-01"
try {
Invoke-Sup212Throttled {
Invoke-RestMethod -Method POST -Uri $uri -Body $body `
-ContentType 'application/json' `
-Headers @{ Authorization = "Bearer $token" }
} | Out-Null
[pscustomobject]@{
Status='Clean'; Reason=''; BatchId=$batchId; EventCount=$enriched.Count
}
} catch {
[pscustomobject]@{
Status='Error'; Reason=$_.Exception.Message; BatchId=$batchId; EventCount=$enriched.Count
}
}
}
13.2 Test-Sup212SiemForwarding
End-to-end canary: emits a known-shape event and queries the target workspace for its appearance. Failure → Anomaly, not Clean.
function Test-Sup212SiemForwarding {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Session,
[string] $ConfigPath = './sup212.config.json',
[int] $WaitSeconds = 180
)
$c = Get-Content $ConfigPath -Raw | ConvertFrom-Json
if (-not $c.sentinelWorkspaceId) {
return [pscustomobject]@{ Status='NotApplicable'; Reason='sentinelWorkspaceId missing in config.' }
}
$canaryId = [guid]::NewGuid().ToString()
$send = Send-Sup212SentinelEvents -Session $Session -Events @(
[pscustomobject]@{ EventType='Sup212Canary'; Payload=@{ canaryId=$canaryId } }
)
if ($send.Status -ne 'Clean') {
return [pscustomobject]@{ Status='Error'; Reason="CanaryForwardFailed: $($send.Reason)" }
}
Start-Sleep -Seconds $WaitSeconds
$kql = "$($c.sentinelStreamName) | where EventType == 'Sup212Canary' | where Payload contains '$canaryId' | take 1"
$q = Invoke-AzOperationalInsightsQuery -WorkspaceId $c.sentinelWorkspaceId -Query $kql
if ($q.Results.Count -eq 0) {
return [pscustomobject]@{
Status='Anomaly'
Reason="CanaryNotObserved after $WaitSeconds s — Sentinel pipeline appears silently dropping events. Investigate DCR stream filters and table schema."
CanaryId=$canaryId
}
}
[pscustomobject]@{ Status='Clean'; Reason=''; CanaryId=$canaryId; Observed=$true }
}
§14 — Scheduling (Power Automate + Scheduled Tasks)
None of the §4–§13 helpers are intended to run interactively in perpetuity. The following schedule aligns with the verification cadence enumerated in the control document and is the pattern we recommend.
| Helper | Cadence | Trigger | Runs as | Notes |
|---|---|---|---|---|
Export-Sup212QueueHealthBundle (§4.5) |
Daily 06:00 UTC | Scheduled task | Managed identity with Graph + Power Platform reader | 7-day rolling window |
Export-Sup212QuarterlyDecisionSample (§5.3) |
Quarterly T+2 (two business days after quarter-end) | Scheduled task | Managed identity with AuditLog.Read.All |
Lookback buffer = 45 min |
Export-Sup212AgentFrameworkEvidence (§6.3) |
Every 6 hours | Scheduled task | App registration, cert-based auth | Idempotent; safe to re-run |
Test-Sup212PrincipalRegistration (§7.1) |
Quarterly T+1 | Scheduled task | Compliance Officer staging share | CRD feed OR manual attestation |
Invoke-Sup212Rule3120Harness (§9.2) |
Annually + on-demand | Pipeline (Azure DevOps / GitHub Actions) | Service principal | Full namespace set |
Invoke-Sup212Rule2210Classifier (§10.1) |
Weekly Monday 07:00 UTC | Power Automate scheduled cloud flow | Runs as marketing-review service account | Population source: marketing-review DB |
Get-Sup212SponsorAttestation (§11.1) |
Quarterly, aligned to access-review cadence | Scheduled task | Managed identity with AccessReview.Read.All |
Skips on sovereign |
Invoke-Sup212SovereignRegister (§12.1) |
Quarterly (sovereign tenants only) | Manual / operator-run | Principal + Governance Lead | Dual signature ceremony |
Test-Sup212SiemForwarding (§13.2) |
Daily 07:00 UTC | Scheduled task | Reader on workspace | Canary event |
Example scheduled-task registration (Windows; mirror on Linux via systemd timers):
$action = New-ScheduledTaskAction -Execute 'pwsh.exe' `
-Argument '-NoProfile -File C:\ops\fsi-agentgov\Run-Sup212Daily.ps1'
$trigger = New-ScheduledTaskTrigger -Daily -At '06:00'
$settings= New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName 'Sup212-Daily' -Action $action -Trigger $trigger `
-Settings $settings -RunLevel Highest -User 'NT AUTHORITY\SYSTEM' -Force
Run-Sup212Daily.ps1 orchestrates §4, §11, §13 and emits a daily manifest; the quarterly orchestrator Run-Sup212Quarterly.ps1 adds §5, §7, §10 and, on sovereign tenants, §12.
§15 — Evidence Retention Discipline
Every artifact produced by this playbook is a books-and-records item under FINRA Rule 4511 and SEC Rule 17a-4(b)(4). Retention discipline:
- Retention period: 6 years from creation (or 6 years from resolution for Agent Framework request tuples). Easily accessible for the first 2 years.
- Medium: WORM-backed storage (Microsoft Purview retention policies, SharePoint/OneDrive retention labels configured for immutability, or a compliant 17a-4(f) audit-trail alternative). The
PimJustificationfield and the SHA-256 artifact hashes in every manifest are the integrity anchors. - Chain-of-custody: Every manifest carries
RunId,TenantId,Cloud.EnvName,PimJustification, and per-artifact SHA-256. Any downstream copy must preserve the manifest; the manifest, not the artifact, is the authoritative evidence unit. - Never edit an emitted bundle. Corrections run as a new
RunIdwith asupersedesfield in the manifest; the prior bundle stays in place. WORM enforces this at the storage layer but the discipline must be an operator habit. - SIEM forwarding (§13) is additive, not a substitute for the WORM copy. The Sentinel record is the correlation layer; the WORM bundle is the regulatory record.
See Control 2.13 — Documentation and Record-Keeping for the firm-level retention policy and Control 1.7 — Comprehensive Audit Logging for SIEM-side retention enforcement.
§16 — Cross-References
Control 2.12 sister playbooks:
- Portal Walkthrough — UI configuration of Copilot Studio handoff, Power Automate approvals, and the supervision register.
- Verification & Testing — Pester suites by namespace (WSP, HITL, QUEUE, REVIEWER, PRINCIPAL, SAMPLING, R3120, R2210, SPONSOR, AGF, SOV, SIEM), manual test scripts, and evidence-collection checklists.
- Troubleshooting — Common issues for each §3–§13 helper and their remediations.
Related controls (framework):
- 1.2 — Agent Registry and Integrated Apps Management — The registry that bounds §4 scope and links agent IDs to zone classification used in §4.3 SLA thresholds and §8 sampling rates.
- 1.7 — Comprehensive Audit Logging and Compliance — SIEM-side enforcement for §13 forwarding.
- 2.6 — Model Risk Management (OCC 2011-12 / SR 11-7) — Supervision is a component of MRM; §9 feeds MRM evidence.
- 2.13 — Documentation and Record-Keeping — Retention policy for every artifact in §15.
- 2.25 — Agent 365 Admin Center Governance Console — Agent 365 admin approvals reference this control as the supervisory anchor; admin approval is not principal supervision.
- 2.26 — Entra Agent ID Identity Governance — Sponsorship consumed by §11 for criterion #8 support; sponsorship is not principal supervision.
- 3.1 — Agent Inventory and Metadata Management — The registry that bounds §4 scope.
- 3.4 — Incident Reporting and Root Cause Analysis — SLA breaches (§4.3) and Agent Framework integrity mismatches (§6.3) open incidents under 3.4.
- 3.6 — Orphaned Agent Detection and Remediation — Orphaned agents re-enter §4 scope until reassignment.
Shared references:
- PowerShell Authoring Baseline for FSI Implementations — Module pinning, mutation safety, SHA-256 manifest format.
- Sovereign-cloud endpoints (GCC / GCC High / DoD) — Endpoint matrix and compensating-control rationale consumed by §3.2 and §12.
- Role Catalog — Canonical role names used throughout this playbook.
- Regulatory Mappings — Regulation-to-control mapping.
Canonical role names (short forms) used in this playbook:
- Compliance Officer
- Designated Principal / Qualified Supervisor
- AI Governance Lead
- AI Administrator
- Agent Owner
Non-substitution reminder. Every helper in this playbook produces evidence that supports compliance with FINRA Rule 3110 (and related rules). None of the helpers — and none of the evidence bundles they produce — substitute for the Designated Principal's supervisory review, the firm's Written Supervisory Procedures, or the registered-principal qualifications required by FINRA Rule 3110. The control is the principal's judgment; this playbook produces the record.
Updated: April 2026 | Version: v1.4.0 | UI Verification Status: Current