Skip to content

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.
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

Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue

Cross-references


Back to Control 1.9 · Portal walkthrough · Verification & testing · Troubleshooting


Updated: April 2026 | Version: v1.4.0