PowerShell Setup: Control 2.21 — AI Marketing Claims and Substantiation
Read the FSI PowerShell baseline first
Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below use the baseline patterns; if a snippet appears to deviate, the baseline wins.
Last Updated: April 2026
Module Requirements: PnP.PowerShell v2+ (PowerShell 7.2+)
Estimated Time: 30–45 minutes (idempotent; safe to re-run)
Audience: SharePoint Admin executing in a CAB-approved change window
Prerequisites
| Item | Details |
|---|---|
| Roles | SharePoint Admin or Site Collection Admin on the target site |
| PowerShell edition | PowerShell 7.2 or later (PnP.PowerShell v2 does not support Windows PowerShell 5.1) |
| Module | PnP.PowerShell pinned to a CAB-approved version |
| App registration | PnP.PowerShell v2 requires an Entra app registration with delegated SharePoint permissions; do not silently upgrade from v1 in production |
| Sovereign cloud | Identify your tenant cloud (Commercial, GCC, GCC High, DoD) before connecting |
| Change ticket | Open and reference in -WhatIf dry runs and final apply runs |
Module Installation (CAB-pinned)
# Replace <version> with the version approved by your Change Advisory Board.
Install-Module -Name PnP.PowerShell `
-RequiredVersion '<version>' `
-Repository PSGallery `
-Scope CurrentUser `
-AllowClobber `
-AcceptLicense
Import-Module PnP.PowerShell -RequiredVersion '<version>'
If your tenant uses GCC High or DoD, append -AzureEnvironment USGovernmentHigh (or USGovernmentDoD) on Connect-PnPOnline. See the baseline for the full sovereign-cloud matrix.
Script 1 — Provision the AI Marketing Claims inventory list (idempotent)
<#
.SYNOPSIS
Provisions the AI Marketing Claims Inventory list for Control 2.21.
.DESCRIPTION
Idempotent setup of the SharePoint list used as the system of record for
AI marketing claim submissions, reviews, and approvals. Supports -WhatIf
via SupportsShouldProcess and emits a SHA-256 evidence record describing
each created or skipped artifact.
.PARAMETER SiteUrl
Full URL of the governance SharePoint site, e.g. https://contoso.sharepoint.com/sites/AIGovernance
.PARAMETER AzureEnvironment
Sovereign-cloud target. One of: Production, USGovernment, USGovernmentHigh,
USGovernmentDoD, China, Germany. Defaults to Production.
.PARAMETER EvidencePath
Folder where the JSON evidence record is written.
.EXAMPLE
.\New-AIClaimsInventory.ps1 -SiteUrl 'https://contoso.sharepoint.com/sites/AIGovernance' -WhatIf
.NOTES
Control: 2.21 — AI Marketing Claims and Substantiation
Last Updated: April 2026
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[ValidateSet('Production','USGovernment','USGovernmentHigh','USGovernmentDoD','China','Germany')]
[string] $AzureEnvironment = 'Production',
[string] $EvidencePath = '.\evidence\2.21'
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Path $EvidencePath -Force | Out-Null
Write-Host "[2.21] Connecting to $SiteUrl ($AzureEnvironment)..." -ForegroundColor Cyan
Connect-PnPOnline -Url $SiteUrl -Interactive -AzureEnvironment $AzureEnvironment
$listName = 'AI Marketing Claims Inventory'
$evidence = [ordered]@{
control = '2.21'
siteUrl = $SiteUrl
listName = $listName
timestampUtc = (Get-Date).ToUniversalTime().ToString('o')
operations = @()
}
# Ensure list exists
$list = Get-PnPList -Identity $listName -ErrorAction SilentlyContinue
if (-not $list) {
if ($PSCmdlet.ShouldProcess($listName, 'Create SharePoint list')) {
$list = New-PnPList -Title $listName -Template GenericList -OnQuickLaunch
$evidence.operations += @{ action = 'create-list'; target = $listName; result = 'created' }
Write-Host "[2.21] Created list: $listName" -ForegroundColor Green
}
} else {
$evidence.operations += @{ action = 'create-list'; target = $listName; result = 'already-exists' }
Write-Host "[2.21] List already exists: $listName" -ForegroundColor Yellow
}
# Field schema (display name, internal name, type, choices)
$fields = @(
@{ Display = 'Claim Text'; Internal = 'ClaimText'; Type = 'Note'; Choices = $null },
@{ Display = 'Claim Category'; Internal = 'ClaimCategory'; Type = 'Choice'; Choices = @('Performance','Capability','Comparative','Predictive','Efficiency') },
@{ Display = 'Agent/Product'; Internal = 'AgentProduct'; Type = 'Text'; Choices = $null },
@{ Display = 'Target Channel'; Internal = 'TargetChannel'; Type = 'Choice'; Choices = @('Website','Email','Social Media','Sales Collateral','Press Release','Conference Material','Multiple') },
@{ Display = 'Governance Zone'; Internal = 'GovernanceZone'; Type = 'Choice'; Choices = @('Zone 1 - Personal','Zone 2 - Team','Zone 3 - Enterprise') },
@{ Display = 'FINRA 2210 Communication'; Internal = 'FinraCommType'; Type = 'Choice'; Choices = @('Correspondence','Retail Communication','Institutional Communication','N/A') },
@{ Display = 'Substantiation File'; Internal = 'SubstantiationFile'; Type = 'URL'; Choices = $null },
@{ Display = 'Status'; Internal = 'ClaimStatus'; Type = 'Choice'; Choices = @('Draft','Under Review','Approved','Rejected','Withdrawn','Retired') },
@{ Display = 'Submitted By'; Internal = 'SubmittedByUser'; Type = 'User'; Choices = $null },
@{ Display = 'Submission Date'; Internal = 'SubmissionDate'; Type = 'DateTime'; Choices = $null },
@{ Display = 'Compliance Reviewer'; Internal = 'ComplianceReviewer'; Type = 'User'; Choices = $null },
@{ Display = 'Review Date'; Internal = 'ReviewDate'; Type = 'DateTime'; Choices = $null },
@{ Display = 'Approval Date'; Internal = 'ApprovalDate'; Type = 'DateTime'; Choices = $null },
@{ Display = 'Next Review Date'; Internal = 'NextReviewDate'; Type = 'DateTime'; Choices = $null },
@{ Display = 'Review Comments'; Internal = 'ReviewComments'; Type = 'Note'; Choices = $null }
)
$existingFields = (Get-PnPField -List $listName | Select-Object -ExpandProperty InternalName)
foreach ($f in $fields) {
if ($existingFields -contains $f.Internal) {
$evidence.operations += @{ action = 'add-field'; target = $f.Internal; result = 'already-exists' }
Write-Host " [skip] $($f.Internal)" -ForegroundColor DarkGray
continue
}
if ($PSCmdlet.ShouldProcess("$listName : $($f.Internal)", "Add field ($($f.Type))")) {
if ($f.Type -eq 'Choice' -and $f.Choices) {
Add-PnPField -List $listName -DisplayName $f.Display -InternalName $f.Internal -Type Choice -Choices $f.Choices | Out-Null
} else {
Add-PnPField -List $listName -DisplayName $f.Display -InternalName $f.Internal -Type $f.Type | Out-Null
}
$evidence.operations += @{ action = 'add-field'; target = $f.Internal; result = 'created' }
Write-Host " [+] $($f.Internal)" -ForegroundColor Green
}
}
# Versioning
if ($PSCmdlet.ShouldProcess($listName, 'Enable versioning (50 major)')) {
Set-PnPList -Identity $listName -EnableVersioning $true -MajorVersions 50 | Out-Null
$evidence.operations += @{ action = 'enable-versioning'; target = $listName; result = 'set'; majorVersions = 50 }
}
# Persist evidence record + SHA-256 hash
$evidenceJson = $evidence | ConvertTo-Json -Depth 6
$evidenceFile = Join-Path $EvidencePath ("2.21-inventory-{0}.json" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
$evidenceJson | Out-File -FilePath $evidenceFile -Encoding utf8
$hash = Get-FileHash -Path $evidenceFile -Algorithm SHA256
"$($hash.Hash) $($hash.Path)" | Out-File -FilePath ($evidenceFile + '.sha256') -Encoding ascii
Write-Host "`n[2.21] Evidence: $evidenceFile (SHA-256 $($hash.Hash))" -ForegroundColor Cyan
Disconnect-PnPOnline
Script 2 — Provision the substantiation library
<#
.SYNOPSIS
Creates the AI Claims Substantiation document library and category folders.
.NOTES
Idempotent. Apply your firm's retention label via Purview policy targeting
this library; this script does not set retention labels (use Purview).
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[ValidateSet('Production','USGovernment','USGovernmentHigh','USGovernmentDoD','China','Germany')]
[string] $AzureEnvironment = 'Production'
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -Interactive -AzureEnvironment $AzureEnvironment
$libName = 'AI Claims Substantiation'
$lib = Get-PnPList -Identity $libName -ErrorAction SilentlyContinue
if (-not $lib -and $PSCmdlet.ShouldProcess($libName, 'Create document library')) {
New-PnPList -Title $libName -Template DocumentLibrary -OnQuickLaunch | Out-Null
Set-PnPList -Identity $libName -EnableVersioning $true -MajorVersions 50 -ForceCheckout $true | Out-Null
Write-Host "[2.21] Created library: $libName" -ForegroundColor Green
} else {
Write-Host "[2.21] Library already exists: $libName" -ForegroundColor Yellow
}
$folders = @('Performance Claims','Capability Claims','Comparative Claims','Predictive Claims','Efficiency Claims')
foreach ($folder in $folders) {
$exists = Get-PnPFolder -Url "$libName/$folder" -ErrorAction SilentlyContinue
if (-not $exists -and $PSCmdlet.ShouldProcess("$libName/$folder", 'Create folder')) {
Add-PnPFolder -Name $folder -Folder $libName | Out-Null
Write-Host " [+] $folder" -ForegroundColor Green
}
}
Disconnect-PnPOnline
Script 3 — Export claims inventory for compliance reporting
<#
.SYNOPSIS
Read-only export of the AI Marketing Claims inventory to CSV with SHA-256 evidence.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[ValidateSet('Production','USGovernment','USGovernmentHigh','USGovernmentDoD','China','Germany')]
[string] $AzureEnvironment = 'Production',
[string] $OutputPath = '.\evidence\2.21'
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
Connect-PnPOnline -Url $SiteUrl -Interactive -AzureEnvironment $AzureEnvironment
$items = Get-PnPListItem -List 'AI Marketing Claims Inventory' -PageSize 500
$rows = foreach ($i in $items) {
[PSCustomObject]@{
ClaimID = $i.Id
ClaimText = $i.FieldValues.ClaimText
Category = $i.FieldValues.ClaimCategory
AgentProduct = $i.FieldValues.AgentProduct
Channel = $i.FieldValues.TargetChannel
Zone = $i.FieldValues.GovernanceZone
FinraCommType = $i.FieldValues.FinraCommType
Status = $i.FieldValues.ClaimStatus
SubmissionDate = $i.FieldValues.SubmissionDate
ApprovalDate = $i.FieldValues.ApprovalDate
NextReviewDate = $i.FieldValues.NextReviewDate
HasSubstantiation = [bool]$i.FieldValues.SubstantiationFile
}
}
$csv = Join-Path $OutputPath ("2.21-claims-{0}.csv" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
$rows | Export-Csv -Path $csv -NoTypeInformation -Encoding UTF8
$hash = Get-FileHash -Path $csv -Algorithm SHA256
"$($hash.Hash) $($hash.Path)" | Out-File -FilePath ($csv + '.sha256') -Encoding ascii
Write-Host "[2.21] Exported $($rows.Count) claims to $csv (SHA-256 $($hash.Hash))" -ForegroundColor Green
$rows | Group-Object Status | Select-Object Name, Count | Format-Table -AutoSize
Disconnect-PnPOnline
Script 4 — Identify claims due for quarterly review
<#
.SYNOPSIS
Read-only query for approved claims with NextReviewDate within the lookahead window.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[ValidateSet('Production','USGovernment','USGovernmentHigh','USGovernmentDoD','China','Germany')]
[string] $AzureEnvironment = 'Production',
[int] $DaysAhead = 14
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -Interactive -AzureEnvironment $AzureEnvironment
$cutoff = (Get-Date).AddDays($DaysAhead).ToString('yyyy-MM-dd')
$caml = @"
<View>
<Query>
<Where>
<And>
<Eq><FieldRef Name='ClaimStatus'/><Value Type='Choice'>Approved</Value></Eq>
<Leq><FieldRef Name='NextReviewDate'/><Value Type='DateTime'>$cutoff</Value></Leq>
</And>
</Where>
<OrderBy><FieldRef Name='NextReviewDate' Ascending='TRUE'/></OrderBy>
</Query>
</View>
"@
$due = Get-PnPListItem -List 'AI Marketing Claims Inventory' -Query $caml
if (-not $due -or $due.Count -eq 0) {
Write-Host "[2.21] No claims due for review within $DaysAhead days." -ForegroundColor Green
} else {
Write-Host "[2.21] $($due.Count) claim(s) due for review:" -ForegroundColor Yellow
$due | ForEach-Object {
$text = [string]$_.FieldValues.ClaimText
$snippet = if ($text.Length -gt 60) { $text.Substring(0,60) + '...' } else { $text }
Write-Host (" ID {0,-5} Due {1:yyyy-MM-dd} {2}" -f $_.Id, $_.FieldValues.NextReviewDate, $snippet)
}
}
Disconnect-PnPOnline
Validation script
<#
.SYNOPSIS
Read-only verification that Control 2.21 SharePoint artifacts exist.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[ValidateSet('Production','USGovernment','USGovernmentHigh','USGovernmentDoD','China','Germany')]
[string] $AzureEnvironment = 'Production'
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -Interactive -AzureEnvironment $AzureEnvironment
$results = @()
$inv = Get-PnPList -Identity 'AI Marketing Claims Inventory' -ErrorAction SilentlyContinue
$results += [PSCustomObject]@{ Check = 'Claims inventory list exists'; Status = if ($inv) {'PASS'} else {'FAIL'} }
$lib = Get-PnPList -Identity 'AI Claims Substantiation' -ErrorAction SilentlyContinue
$results += [PSCustomObject]@{ Check = 'Substantiation library exists'; Status = if ($lib) {'PASS'} else {'FAIL'} }
$required = @('ClaimText','ClaimCategory','ClaimStatus','SubstantiationFile','NextReviewDate','GovernanceZone','FinraCommType')
if ($inv) {
$present = (Get-PnPField -List 'AI Marketing Claims Inventory').InternalName
$missing = $required | Where-Object { $_ -notin $present }
$results += [PSCustomObject]@{ Check = 'Required fields configured'; Status = if (-not $missing) {'PASS'} else {"FAIL - missing: $($missing -join ', ')"} }
}
if ($lib) {
$folders = @('Performance Claims','Capability Claims','Comparative Claims','Predictive Claims','Efficiency Claims')
$missingF = $folders | Where-Object { -not (Get-PnPFolder -Url "AI Claims Substantiation/$_" -ErrorAction SilentlyContinue) }
$results += [PSCustomObject]@{ Check = 'Category folders present'; Status = if (-not $missingF) {'PASS'} else {"FAIL - missing: $($missingF -join ', ')"} }
}
Write-Host "`n=== Control 2.21 Validation ===" -ForegroundColor Cyan
$results | Format-Table -AutoSize
Disconnect-PnPOnline
Workflow validation is a portal task
Power Automate flows must be validated in the maker portal (Step 5 of the portal walkthrough). The validation script above intentionally does not modify or test flows; instead, run the test cases in the Verification & Testing playbook.
Safety Checklist
- Module pinned to a CAB-approved
RequiredVersion - PowerShell 7.2+ confirmed (
$PSVersionTable.PSVersion) -
-AzureEnvironmentmatches the tenant cloud - Each mutating script run with
-WhatIffirst; output reviewed against change ticket - Evidence JSON / CSV files retained with
.sha256sidecar -
Disconnect-PnPOnlineruns even on failure (consider wrapping calls intry/finallyfor production)
Back to Control 2.21 | Portal Walkthrough | Verification & Testing | Troubleshooting