Control 1.9 — PowerShell Setup: Data Retention and Deletion Policies
Control: 1.9 — Data Retention and Deletion Policies Pillar: Pillar 1 — Security Audience: Purview Records Manager, Purview Compliance Admin, Power Platform Admin Companion playbooks: Portal walkthrough · Verification & testing · Troubleshooting
Read the FSI PowerShell baseline first
Before running anything below, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for ExchangeOnlineManagement version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), and SHA-256 evidence emission. Snippets below assume that baseline is in effect.
This playbook automates Control 1.9 via Security & Compliance PowerShell (the IPPS endpoint exposed by
ExchangeOnlineManagement). Every mutating command below is wrapped in safety checks, supports-WhatIf, and writes evidence artifacts intended for examiner production.Hedging note. This automation helps meet, and is recommended to support compliance with, SEC 17 CFR 240.17a-4, FINRA 4511, SOX §404 / §802, GLBA 501(b), CFTC 1.31, and IRS recordkeeping rules. It does not by itself satisfy 17a-4(f) — see the parent control's "SEC 17a-4(f) caveat."
§1. Pre-flight
1.1 Module and edition
| Module | Required version | Edition |
|---|---|---|
ExchangeOnlineManagement |
Pin to a CAB-approved stable production release | Desktop (5.1) or Core (7.2+) |
# Pin to a CAB-approved version. Substitute <version>.
Install-Module -Name ExchangeOnlineManagement `
-RequiredVersion '<version>' `
-Repository PSGallery `
-Scope CurrentUser `
-AllowClobber `
-AcceptLicense
1.2 Required role
The signed-in account must hold Purview Records Manager and Purview Compliance Admin to author labels, policies, and apply Preservation Lock. For Zone 3 lock application, require a second-person review (named in the change ticket) before the lock cmdlet runs.
1.3 Connect to Security & Compliance
# Commercial cloud
Connect-IPPSSession -ShowBanner:$false
# GCC High
# Connect-IPPSSession -ConnectionUri https://ps.compliance.protection.office365.us/powershell-liveid/ `
# -AzureADAuthorizationEndpointUri https://login.microsoftonline.us/common -ShowBanner:$false
# DoD
# Connect-IPPSSession -ConnectionUri https://l5.ps.compliance.protection.office365.us/powershell-liveid/ `
# -AzureADAuthorizationEndpointUri https://login.microsoftonline.us/common -ShowBanner:$false
Verify the session:
Get-ConnectionInformation | Where-Object { $_.ConnectionUri -like '*compliance*' } |
Select-Object UserPrincipalName, ConnectionUri, State
1.4 Common parameters used below
$EvidenceRoot = 'C:\fsi-evidence\1.9'
$null = New-Item -ItemType Directory -Path $EvidenceRoot -Force
$Stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
$ReviewerStage1 = 'records-mgmt-stage1@contoso.com'
$ReviewerStage2 = 'compliance-stage2@contoso.com'
$ReviewerStage3 = 'legal-stage3@contoso.com'
§2. Idempotent label creation
New-ComplianceTag fails if the tag exists. Wrap creates in an idempotency check and use Set-ComplianceTag for updates. Set-ComplianceTag cannot reduce retention on a record/regulatory label — see §6.
2.1 Helper: ensure a label exists
function Ensure-ComplianceTag {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Comment,
[Parameter(Mandatory)] [int] $RetentionDurationDays,
[Parameter(Mandatory)] [ValidateSet('Keep','Delete','KeepAndDelete')] [string] $RetentionAction,
[bool] $IsRecordLabel = $false,
[bool] $Regulatory = $false,
[string] $ReviewerEmail
)
$existing = Get-ComplianceTag -Identity $Name -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "[SKIP] Label '$Name' already exists." -ForegroundColor Yellow
return $existing
}
if ($PSCmdlet.ShouldProcess($Name, "Create retention label")) {
$params = @{
Name = $Name
Comment = $Comment
RetentionDuration = $RetentionDurationDays
RetentionAction = $RetentionAction
RetentionType = 'CreationAgeInDays'
}
if ($IsRecordLabel) { $params.IsRecordLabel = $true }
if ($Regulatory) { $params.Regulatory = $true }
if ($ReviewerEmail) { $params.ReviewerEmail = $ReviewerEmail }
New-ComplianceTag @params -ErrorAction Stop |
Tee-Object -FilePath (Join-Path $EvidenceRoot "label-$Name-$Stamp.json")
Write-Host "[CREATED] Label '$Name'." -ForegroundColor Green
}
}
2.2 Create the FSI label set
# Communications classification (3 years)
Ensure-ComplianceTag -Name 'FSI-Agent-Communications-3Year' `
-Comment 'Agent transcript classified as a communication under SEC 17a-4(b)(4). 3-year retention.' `
-RetentionDurationDays 1095 `
-RetentionAction KeepAndDelete `
-IsRecordLabel $true `
-ReviewerEmail $ReviewerStage2
# Books-and-records (6 years)
Ensure-ComplianceTag -Name 'FSI-Agent-BooksRecords-6Year' `
-Comment 'Agent transcript that evidences or generates a 17a-3 record. 6-year retention.' `
-RetentionDurationDays 2190 `
-RetentionAction KeepAndDelete `
-IsRecordLabel $true `
-ReviewerEmail $ReviewerStage2
# Regulatory record (10 years, immutable)
Ensure-ComplianceTag -Name 'FSI-Agent-RegRecord-10Year' `
-Comment 'Agent audit metadata and regulated artifacts. 10-year retention; immutable.' `
-RetentionDurationDays 3650 `
-RetentionAction KeepAndDelete `
-IsRecordLabel $true `
-Regulatory $true `
-ReviewerEmail $ReviewerStage3
# Agent configuration (6 years, automatic deletion)
Ensure-ComplianceTag -Name 'FSI-Agent-Configuration-6Year' `
-Comment 'Agent definitions, version history, exports. 6-year retention.' `
-RetentionDurationDays 2190 `
-RetentionAction Delete
Regulatory record is one-way
Once Regulatory = $true is set on a label, the label cannot be unmarked or deleted, items it has been applied to cannot have the label removed, and retention can only be extended. Treat creation of a regulatory label as a SEV-2 change.
§3. Idempotent retention-policy creation for AI surfaces
The exact location parameter names for Microsoft 365 Copilot and AI experiences, Enterprise AI Apps, and Other AI Apps evolve as Microsoft adds capabilities. Always cross-check against New-RetentionCompliancePolicy immediately before a production change.
3.1 Helper: ensure a policy + rule exist
function Ensure-RetentionPolicy {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $PolicyName,
[Parameter(Mandatory)] [string] $RuleName,
[Parameter(Mandatory)] [int] $RetentionDurationDays,
[Parameter(Mandatory)] [hashtable] $LocationParams,
[string] $ContentMatchQuery
)
$existing = Get-RetentionCompliancePolicy -Identity $PolicyName -ErrorAction SilentlyContinue
if (-not $existing) {
if ($PSCmdlet.ShouldProcess($PolicyName, "Create retention policy")) {
New-RetentionCompliancePolicy -Name $PolicyName @LocationParams -ErrorAction Stop |
Out-Null
Write-Host "[CREATED] Policy '$PolicyName'." -ForegroundColor Green
}
} else {
Write-Host "[SKIP] Policy '$PolicyName' already exists." -ForegroundColor Yellow
}
$rule = Get-RetentionComplianceRule -Policy $PolicyName -ErrorAction SilentlyContinue |
Where-Object Name -eq $RuleName
if (-not $rule) {
if ($PSCmdlet.ShouldProcess($RuleName, "Create retention rule")) {
$ruleParams = @{
Name = $RuleName
Policy = $PolicyName
RetentionDuration = $RetentionDurationDays
RetentionComplianceAction = 'KeepAndDelete'
}
if ($ContentMatchQuery) { $ruleParams.ContentMatchQuery = $ContentMatchQuery }
New-RetentionComplianceRule @ruleParams -ErrorAction Stop | Out-Null
Write-Host "[CREATED] Rule '$RuleName'." -ForegroundColor Green
}
} else {
Write-Host "[SKIP] Rule '$RuleName' already exists." -ForegroundColor Yellow
}
}
3.2 Microsoft 365 Copilot and AI experiences
Ensure-RetentionPolicy -PolicyName 'FSI-Copilot-AIExperiences-Retention' `
-RuleName 'FSI-Copilot-AIExperiences-6Year' `
-RetentionDurationDays 2190 `
-LocationParams @{ ModernGroupLocation = 'All' }
# NOTE: The exact location switch for Copilot/AI experiences may be named differently
# (e.g., -CopilotLocation, -CopilotExperiencesLocation, -ModernGroupLocation in some
# configurations). Verify with `Get-Help New-RetentionCompliancePolicy -Full` against
# your pinned ExchangeOnlineManagement module before running in production.
3.3 Agent-related Exchange email retention (content-match safety net)
Ensure-RetentionPolicy -PolicyName 'FSI-AgentEmail-Retention' `
-RuleName 'FSI-AgentEmail-6Year' `
-RetentionDurationDays 2190 `
-LocationParams @{ ExchangeLocation = 'All' } `
-ContentMatchQuery '(Copilot OR "agent" OR "AI assistant" OR chatbot)'
3.4 SharePoint / OneDrive container retention
Ensure-RetentionPolicy -PolicyName 'FSI-AgentSharePoint-Retention' `
-RuleName 'FSI-AgentSharePoint-6Year' `
-RetentionDurationDays 2190 `
-LocationParams @{ SharePointLocation = 'All'; OneDriveLocation = 'All' }
§4. Publish the FSI agent labels
$LabelPolicyName = 'FSI-Agent-Labels-Publish-Zone3'
$existing = Get-RetentionCompliancePolicy -Identity $LabelPolicyName -ErrorAction SilentlyContinue
if (-not $existing) {
New-RetentionCompliancePolicy -Name $LabelPolicyName `
-ExchangeLocation 'All' `
-SharePointLocation 'All' `
-OneDriveLocation 'All' `
-ModernGroupLocation 'All' `
-Comment 'Publishes FSI Control 1.9 retention labels to Zone 3 in-scope locations.' |
Out-Null
}
$AgentLabels = Get-ComplianceTag | Where-Object { $_.Name -like 'FSI-Agent-*' }
$RuleName = 'FSI-Agent-Labels-Rule'
$rule = Get-RetentionComplianceRule -Policy $LabelPolicyName -ErrorAction SilentlyContinue |
Where-Object Name -eq $RuleName
if (-not $rule) {
New-RetentionComplianceRule -Name $RuleName `
-Policy $LabelPolicyName `
-PublishComplianceTag $AgentLabels.Name | Out-Null
}
Verify distribution:
Get-RetentionCompliancePolicy -Identity $LabelPolicyName |
Select-Object Name, Mode, Enabled, DistributionStatus
§5. Audit-log retention for deletion events
$AuditPolicyName = 'FSI-DeletionAudit-10Year'
$existing = Get-UnifiedAuditLogRetentionPolicy -Identity $AuditPolicyName -ErrorAction SilentlyContinue
if (-not $existing) {
New-UnifiedAuditLogRetentionPolicy -Name $AuditPolicyName `
-Description 'Extended retention for retention/disposition/deletion events (Control 1.9).' `
-Operations FileDeleted, FileVersionRecycled, HardDelete, SoftDelete, MoveToDeletedItems, `
ApplyRetentionLabel, RemoveRetentionLabel, `
NewRetentionComplianceRule, SetRetentionCompliancePolicy `
-RetentionDuration TenYears `
-Priority 100
}
Audit-log retention beyond Microsoft 365 Audit (Standard) requires Microsoft 365 Audit (Premium) and the appropriate per-user license. See Control 1.7.
§6. Preservation Lock — irreversible
STOP. Read this entire section before running the lock cmdlet. After this completes, the policy cannot be disabled, deleted, or shortened by anyone — including Microsoft Support.
6.1 Pre-lock validation
function Test-PolicyReadyForLock {
param([Parameter(Mandatory)] [string] $PolicyName)
$p = Get-RetentionCompliancePolicy -Identity $PolicyName -DistributionDetail
$r = Get-RetentionComplianceRule -Policy $PolicyName
$issues = @()
if (-not $p) { $issues += 'Policy not found.' }
if ($p.Enabled -ne $true) { $issues += 'Policy is not Enabled.' }
if ($p.Mode -ne 'Enforce') { $issues += "Policy Mode is '$($p.Mode)' (not 'Enforce')." }
if ($p.DistributionStatus -ne 'Success') { $issues += "DistributionStatus is '$($p.DistributionStatus)'." }
if ($p.RetentionComplianceLockType -eq 'Lock') { $issues += 'Policy is already Locked.' }
if (-not $r) { $issues += 'No rules attached to policy.' }
foreach ($rule in $r) {
if ($rule.RetentionDuration -lt 1095) {
$issues += "Rule '$($rule.Name)' retention is $($rule.RetentionDuration) days (< 3 years floor)."
}
}
if ($issues) {
Write-Host "[BLOCK] '$PolicyName' is NOT ready for lock:" -ForegroundColor Red
$issues | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
return $false
}
Write-Host "[OK] '$PolicyName' passed pre-lock validation." -ForegroundColor Green
return $true
}
6.2 Apply Preservation Lock (with explicit confirmation)
function Lock-RetentionPolicy {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $PolicyName,
[Parameter(Mandatory)] [string] $ChangeTicket,
[Parameter(Mandatory)] [string] $SecondReviewerUpn
)
if (-not (Test-PolicyReadyForLock -PolicyName $PolicyName)) { return }
$confirmation = Read-Host "Type the policy name '$PolicyName' to confirm IRREVERSIBLE Preservation Lock"
if ($confirmation -ne $PolicyName) {
Write-Host "[ABORT] Confirmation did not match. No action taken." -ForegroundColor Yellow
return
}
if ($PSCmdlet.ShouldProcess($PolicyName,
"Apply Preservation Lock (IRREVERSIBLE) under ticket $ChangeTicket, reviewed by $SecondReviewerUpn")) {
Set-RetentionCompliancePolicy -Identity $PolicyName `
-RetentionComplianceLockType Lock -ErrorAction Stop
$evidence = Get-RetentionCompliancePolicy -Identity $PolicyName |
Select-Object Name, Mode, Enabled, RetentionComplianceLockType, WhenChanged
$evidence | ConvertTo-Json |
Out-File (Join-Path $EvidenceRoot "lock-$PolicyName-$Stamp.json")
Write-Host "[LOCKED] '$PolicyName'. Evidence saved." -ForegroundColor Green
}
}
# Example (Zone 3 only):
# Lock-RetentionPolicy -PolicyName 'FSI-Copilot-AIExperiences-Retention' `
# -ChangeTicket 'CHG-0123456' -SecondReviewerUpn 'records.mgr@contoso.com'
6.3 Locked-policy gotchas
| Gotcha | Reality | Mitigation |
|---|---|---|
| "Locked" prevents all changes | False — you can still add locations and extend retention | Document permitted change paths in the change ticket |
| Lock can be removed by Global Admin | False — no role can unlock | Treat lock as a CAB SEV-2 change with second-person review |
| Lock applies to label policies too | True — Set-RetentionCompliancePolicy -RetentionComplianceLockType Lock works on the policy that publishes labels |
Lock the publish-policy after labels stabilize |
| Lock affects existing rules retroactively | True — but new rules added after lock are also locked | Add all rules before locking |
| Lock survives tenant-to-tenant migration | Microsoft does not support migrating locked policies in tenant moves | Plan the records strategy with the tenant lifecycle in mind |
§7. Evidence export
$labels = Get-ComplianceTag | Where-Object { $_.Name -like 'FSI-Agent-*' } |
Select-Object Name, RetentionDuration, RetentionAction, IsRecordLabel, Regulatory, ReviewerEmail, WhenCreated, WhenChanged
$labels | Export-Csv (Join-Path $EvidenceRoot "labels-$Stamp.csv") -NoTypeInformation
$policies = Get-RetentionCompliancePolicy | Where-Object { $_.Name -like 'FSI-*' } |
Select-Object Name, Mode, Enabled, DistributionStatus, RetentionComplianceLockType, WhenCreated, WhenChanged
$policies | Export-Csv (Join-Path $EvidenceRoot "policies-$Stamp.csv") -NoTypeInformation
$rules = foreach ($p in $policies) {
Get-RetentionComplianceRule -Policy $p.Name -ErrorAction SilentlyContinue |
Select-Object @{n='PolicyName';e={$p.Name}}, Name, RetentionDuration, RetentionComplianceAction, ContentMatchQuery
}
$rules | Export-Csv (Join-Path $EvidenceRoot "rules-$Stamp.csv") -NoTypeInformation
# SHA-256 evidence stamp (per FSI baseline)
Get-ChildItem -Path $EvidenceRoot -Filter "*-$Stamp.*" | ForEach-Object {
[PSCustomObject]@{
File = $_.Name
SHA256 = (Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash
}
} | Export-Csv (Join-Path $EvidenceRoot "evidence-manifest-$Stamp.csv") -NoTypeInformation
§8. Cleanup
Cross-references
- Control 1.7 — Comprehensive Audit Logging and Compliance
- Microsoft Learn:
New-ComplianceTag - Microsoft Learn:
New-RetentionCompliancePolicy - Microsoft Learn: Use Preservation Lock
Back to Control 1.9 · Portal walkthrough · Verification & testing · Troubleshooting
Updated: April 2026 | Version: v1.4.0