Audit Retention
Operator runbook for the hourly background sweep that deletes old rows from IdentityMesh’s audit and history tables.
Tables on the sweep
| Table | Timestamp column | Policy source | Why |
|---|---|---|---|
IM_ObjectAudit | ChangedOn | Retention.AuditDays | Identity-data change evidence (SOX/SOC2) |
IM_AdminAudit | WhenUtc | Retention.AuditDays | Admin-action evidence — same compliance bar |
IM_GroupAuditEvents | WhenUtc | Retention.AuditDays | Self-service group evidence — same compliance bar |
IM_RunHistory | StartTime | Retention.RunHistoryDays | Sync-run telemetry — operational, not compliance |
IM_ConnectorLogs | Timestamp | Operator config | Per-run connector log lines — operational |
IM_Notifications | CreatedUtc | Operator config | Portal inbox — not evidence; purged read or unread |
The sweep starts 5 minutes after the Sync Engine boots, then runs
on a 1-hour cadence. Each cadence pass walks all six tables in
order using DELETE TOP (1000) batched loops so a long-overdue
purge doesn’t lock the table for the duration.
IM_VaultAuditEvents is not on the sweep. Vault audit rows
accumulate indefinitely today; if that matters for a deployment,
archive or purge them by hand until the sweep covers them.
Policies
Audit evidence — one window for all three tables
IM_ObjectAudit, IM_AdminAudit and IM_GroupAuditEvents share
a single operator-configured window: the Retention.AuditDays
system setting, default 30 days. One window means one number
to explain to a compliance reviewer, rather than a different
answer per table.
A value of 0 or negative is the explicit “keep forever” opt-in — it skips all three tables. An unset, blank or unparseable value falls back to 30, so a typo in the settings box cannot silently stop the purge and let the tables grow unbounded.
The license’s audit-retention figure does not drive this. It is
shown on the Operations posture panel and returned by the license
endpoint as a statement of what the tier entitles you to, but it is
informational. Retention.AuditDays is the only thing the purge
reads.
Raising the window is a real trade-off, not a free setting. These
tables are hash-chained and grow roughly one row per changed
object per import, so a long window inflates them and slows every
import and export — each write stamps against the chain head.
Deployments that need long history are usually better served by
shipping the audit feed to a SIEM (see
siem-integration.md) and keeping the
in-database window short.
Purging and the hash chain
Ageing by a timestamp removes a prefix — the oldest rows — so every surviving row still points at its surviving predecessor and the chain stays internally verifiable. Nothing in the sweep can delete from the middle, which is the case that would break verification permanently.
Expect one artefact, and do not read it as tampering: a full-history chain verification on a purged deployment reports exactly one broken link at the retention boundary, because the oldest surviving row names a predecessor retention deleted. The bounded verification (the Operations posture’s recent-tail walk) seeds from its own window’s entry point and is unaffected.
Operational logs
IM_RunHistory, IM_ConnectorLogs and IM_Notifications aren’t
compliance evidence, they’re operational data.
Run history is set from the Admin Portal, via the
Retention.RunHistoryDays system setting (default 90, 0 = keep
forever). The appsettings.json key below still applies when no
system setting has been stored, so a deployment that tuned it
there keeps its window after upgrading. Connector logs and
notifications are appsettings.json only:
"IdentityMesh": {
"Retention": {
"RunHistoryDays": 90,
"ConnectorLogDays": 30,
"NotificationsDays": 90
}
}
Defaults are 90 / 30 / 90. Any value ≤ 0 disables that specific
table’s purge — useful when you want to keep run history forever
for trend analysis, or when you’re already shipping connector logs
to an external system (Splunk, Sentinel, OTLP) and the in-DB copy
is just a triage convenience.
IM_Notifications is the self-service group portal inbox (new in
this release): one row per recipient per event, and expiry warnings
fan out to every owner at three thresholds, which makes it the
fastest-growing table on this sweep. Every row past the window
goes, read or unread. An undismissed notice about a group that
expired last year is noise, and treating “unread” as “keep” would
pin rows forever for exactly the people who never open the bell.
It is an inbox, not evidence — the audit trail of what happened to
a group is IM_GroupAuditEvents, on the compliance window above.
What you can verify
Did the sweep run?
The sweep writes a log line at INFO level to the Sync Engine log whenever it actually deletes anything:
Audit retention: purged 1234 rows from IM_AdminAudit older than 2026-01-25T00:00:00Z.
Idle sweeps log nothing — the absence of “purged” lines for a few days is a signal that either retention is working steadily (rows fall off as fast as they arrive) or that the schedule isn’t firing (check service health).
How many rows were purged?
The OpenTelemetry meter IdentityMesh.Engine exposes:
identitymesh.audit.purged{table="IM_ObjectAudit"}
identitymesh.audit.purged{table="IM_AdminAudit"}
identitymesh.audit.purged{table="IM_GroupAuditEvents"}
identitymesh.audit.purged{table="IM_RunHistory"}
identitymesh.audit.purged{table="IM_ConnectorLogs"}
A monotonically increasing counter. If you’ve wired the OTLP
exporter (Telemetry:Otlp:Enabled = true), this lands in your
metrics backend; otherwise it’s still observable via
dotnet-counters monitor.
Direct SQL spot-check
-- "Oldest row still in IM_ObjectAudit" — should be ≈ retention window
SELECT MIN([ChangedOn]) AS OldestRow,
DATEDIFF(DAY, MIN([ChangedOn]), SYSUTCDATETIME()) AS AgeDays
FROM dbo.IM_ObjectAudit;
-- Same shape, IM_AdminAudit
SELECT MIN([WhenUtc]) AS OldestRow,
DATEDIFF(DAY, MIN([WhenUtc]), SYSUTCDATETIME()) AS AgeDays
FROM dbo.IM_AdminAudit;
-- Same shape, self-service group audit
SELECT MIN([WhenUtc]) AS OldestRow,
DATEDIFF(DAY, MIN([WhenUtc]), SYSUTCDATETIME()) AS AgeDays
FROM dbo.IM_GroupAuditEvents;
If AgeDays exceeds the policy by more than ~24 hours, the sweep
is stuck — investigate.
How to override
Audit tables — the one setting
IM_ObjectAudit, IM_AdminAudit and IM_GroupAuditEvents all
move together on Retention.AuditDays. There is no per-table
window, by design — one window is one answer for a reviewer.
If you need a one-off “preserve this evidence for an investigation past the normal window,” the right move is to copy the relevant rows to a separate archive table before the window expires:
SELECT *
INTO dbo.IM_AdminAudit_Archive_2026Q1
FROM dbo.IM_AdminAudit
WHERE WhenUtc >= '2026-01-01' AND WhenUtc < '2026-04-01';
The archive table sits outside the sweep’s purview and isn’t
governed by Retention.AuditDays. The same shape works for
IM_GroupAuditEvents.
Operational tables — config
Set the operator keys in appsettings.json and restart the Sync
Engine:
# Bump connector-log retention from 30 to 180 days while
# debugging a multi-month integration.
$cfg = "$env:ProgramFiles\IdentityMesh\engine\appsettings.json"
$json = Get-Content $cfg -Raw | ConvertFrom-Json
$json.IdentityMesh.Retention.ConnectorLogDays = 180
$json | ConvertTo-Json -Depth 10 | Set-Content $cfg -Encoding UTF8
sc stop IdentityMeshEngine
sc start IdentityMeshEngine
Or, to disable the connector-log sweep entirely (rows accumulate indefinitely — only do this if you’re shipping to an external log system that has its own retention):
"ConnectorLogDays": 0
One-off aggressive purge
Upgrading a deployment with millions of rows already past the window? The hourly sweep only purges 1000 per pass per table, so catching up takes a while. To force a faster catch-up:
-- 100k-row chunks until the table is current. WAITFOR adds
-- breathing room between chunks so the engine + Admin API can
-- still query the table.
DECLARE @cutoff datetime2 = DATEADD(DAY, -90, SYSUTCDATETIME());
WHILE 1 = 1
BEGIN
DELETE TOP (100000) FROM dbo.IM_ConnectorLogs WHERE [Timestamp] < @cutoff;
IF @@ROWCOUNT < 100000 BREAK;
WAITFOR DELAY '00:00:01';
END
What’s NOT swept
Some tables grow but aren’t on the retention schedule, by design:
IM_MeshObjectsandIM_MeshObjectAttributes— these are the identity data itself; deletion happens via connector semantics, not retention.IM_Connectors,IM_JoinRules,IM_AttributeFlowRules,IM_ProjectionRules,IM_ComposerRules(and friends) — configuration, not history. Operators delete via the Admin API.IM_Secrets— connector credentials. Lifecycle tied to the parent connector; rotation persecret-rotation.md.IM_ExportQueue— queue items markComplete/Failedand stay; IdentityMesh does not purge them. A completion-age policy is planned.IM_ExportedAttributeSnapshots— current state per (mesh-object, connector); deletion happens when the connector or mesh object is removed.IM_VaultAuditEvents— see above; archive or purge by hand for now.
Related
self-service-groups.md— whatIM_GroupAuditEventsrecords and why it shares this window; what the notification inbox is.audit-chain.md— the tamper-evident hash chain and what a purge does to verification.siem-integration.md— ship the audit feed out so the in-database window can stay short.secret-rotation.md— companion runbook for the other recurring operator chore.