diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditResourceEnricherTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditResourceEnricherTests.cs
new file mode 100644
index 000000000..3befd6f67
--- /dev/null
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditResourceEnricherTests.cs
@@ -0,0 +1,107 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using StellaOps.Audit.Emission;
+using StellaOps.Authority.Audit;
+using StellaOps.Authority.Persistence.Postgres.Models;
+using StellaOps.Authority.Persistence.Postgres.Repositories;
+
+namespace StellaOps.Authority.Tests.Audit;
+
+///
+/// Tests for : GUID-to-display-name resolution,
+/// unknown ID fallback, and null/empty handling.
+///
+public class AuthorityAuditResourceEnricherTests
+{
+ private readonly Mock _userRepo = new();
+ private readonly Mock _clientRepo = new();
+ private readonly Mock _roleRepo = new();
+ private readonly Mock _tenantRepo = new();
+ private readonly ILogger _logger =
+ NullLogger.Instance;
+
+ private AuthorityAuditResourceEnricher CreateEnricher()
+ {
+ return new AuthorityAuditResourceEnricher(
+ _userRepo.Object,
+ _clientRepo.Object,
+ _roleRepo.Object,
+ _tenantRepo.Object,
+ _logger);
+ }
+
+ [Fact]
+ public async Task EnrichAsync_ResolvesUserGuid_ToDisplayName()
+ {
+ var userId = Guid.NewGuid();
+ var tenantId = Guid.NewGuid();
+ var tenantEntity = new TenantEntity { Id = tenantId, Name = "demo-prod", Slug = "demo-prod" };
+ var userEntity = new UserEntity
+ {
+ Id = userId,
+ TenantId = tenantId.ToString(),
+ Username = "alice",
+ Email = "alice@test.com",
+ DisplayName = "Alice Admin"
+ };
+
+ _tenantRepo.Setup(r => r.GetAllAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new[] { tenantEntity });
+ _userRepo.Setup(r => r.GetByIdAsync(tenantId.ToString(), userId, It.IsAny()))
+ .ReturnsAsync(userEntity);
+
+ var enricher = CreateEnricher();
+ var result = await enricher.EnrichAsync("user", userId.ToString(), CancellationToken.None);
+
+ Assert.Equal("user", result.Type);
+ Assert.Equal(userId.ToString(), result.Id);
+ Assert.NotNull(result.Name);
+ Assert.Contains("Alice Admin", result.Name);
+ Assert.Contains("alice@test.com", result.Name);
+ }
+
+ [Fact]
+ public async Task EnrichAsync_UnknownId_ReturnsFallbackWithoutName()
+ {
+ var unknownId = Guid.NewGuid();
+ var tenantId = Guid.NewGuid();
+ var tenantEntity = new TenantEntity { Id = tenantId, Name = "test", Slug = "test" };
+
+ _tenantRepo.Setup(r => r.GetAllAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new[] { tenantEntity });
+ _userRepo.Setup(r => r.GetByIdAsync(It.IsAny(), unknownId, It.IsAny()))
+ .ReturnsAsync((UserEntity?)null);
+
+ var enricher = CreateEnricher();
+ var result = await enricher.EnrichAsync("user", unknownId.ToString(), CancellationToken.None);
+
+ Assert.Equal("user", result.Type);
+ Assert.Equal(unknownId.ToString(), result.Id);
+ Assert.Null(result.Name);
+ }
+
+ [Fact]
+ public async Task EnrichAsync_NullOrEmptyId_ReturnsFallbackGracefully()
+ {
+ var enricher = CreateEnricher();
+
+ // Non-GUID resource ID: ResolveUserNameAsync returns null for non-parseable GUIDs
+ var result = await enricher.EnrichAsync("user", "", CancellationToken.None);
+
+ Assert.Equal("user", result.Type);
+ Assert.Equal("", result.Id);
+ Assert.Null(result.Name);
+ }
+
+ [Fact]
+ public void Module_IsAuthority()
+ {
+ var enricher = CreateEnricher();
+ Assert.Equal(AuditModules.Authority, enricher.Module);
+ }
+}
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/AuditCleanseJobPlugin.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/AuditCleanseJobPlugin.cs
new file mode 100644
index 000000000..110628ae5
--- /dev/null
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.AuditCleanse/AuditCleanseJobPlugin.cs
@@ -0,0 +1,216 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+using System.Text.Json;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using StellaOps.Scheduler.Models;
+
+namespace StellaOps.Scheduler.Plugin.AuditCleanse;
+
+///
+/// Scheduler job plugin that performs retention-based deletion of stale audit events.
+/// Supports configurable retention window, batch size, schema targeting, and dry-run mode.
+///
+public sealed class AuditCleanseJobPlugin : ISchedulerJobPlugin
+{
+ ///
+ public string JobKind => "audit-cleanse";
+
+ ///
+ public string DisplayName => "Audit Event Cleanse";
+
+ ///
+ public Version Version { get; } = new(1, 0, 0);
+
+ ///
+ public Task CreatePlanAsync(JobPlanContext context, CancellationToken ct)
+ {
+ var config = ExtractConfig(context.Schedule.PluginConfig);
+ var payload = new Dictionary
+ {
+ ["retentionDays"] = config.RetentionDays,
+ ["batchSize"] = config.BatchSize,
+ ["schemas"] = config.Schemas,
+ ["dryRun"] = config.DryRun,
+ ["scheduleId"] = context.Schedule.Id,
+ };
+
+ return Task.FromResult(new JobPlan(
+ JobKind: "audit-cleanse",
+ Payload: payload,
+ EstimatedSteps: config.Schemas.Count));
+ }
+
+ ///
+ public async Task ExecuteAsync(JobExecutionContext context, CancellationToken ct)
+ {
+ var config = ExtractConfig(context.Plan.Payload);
+ var cutoff = context.TimeProvider.GetUtcNow().AddDays(-config.RetentionDays);
+ var logger = context.Services.GetService()?.CreateLogger();
+
+ var totalDeleted = 0;
+ var step = 0;
+
+ foreach (var schema in config.Schemas)
+ {
+ ct.ThrowIfCancellationRequested();
+ step++;
+
+ if (config.DryRun)
+ {
+ logger?.LogInformation(
+ "[DRY RUN] Would cleanse audit events older than {Cutoff} in schema {Schema} (batch {BatchSize})",
+ cutoff, schema, config.BatchSize);
+ await context.Reporter.ReportProgressAsync(step, config.Schemas.Count,
+ $"[DRY RUN] Schema {schema}: would cleanse", ct).ConfigureAwait(false);
+ continue;
+ }
+
+ // Actual deletion would happen here via a repository
+ var deleted = await DeleteStaleEventsAsync(schema, cutoff, config.BatchSize, context.Services, ct)
+ .ConfigureAwait(false);
+
+ totalDeleted += deleted;
+ logger?.LogInformation(
+ "Cleansed {Count} audit events older than {Cutoff} in schema {Schema}",
+ deleted, cutoff, schema);
+
+ await context.Reporter.ReportProgressAsync(step, config.Schemas.Count,
+ $"Schema {schema}: cleansed {deleted} events", ct).ConfigureAwait(false);
+ }
+
+ await context.Reporter.AppendLogAsync(
+ config.DryRun
+ ? $"Dry run complete for {config.Schemas.Count} schemas"
+ : $"Cleansed {totalDeleted} total events across {config.Schemas.Count} schemas",
+ ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public Task ValidateConfigAsync(
+ IReadOnlyDictionary pluginConfig,
+ CancellationToken ct)
+ {
+ var errors = new List();
+ var config = ExtractConfig(pluginConfig);
+
+ if (config.RetentionDays < 1)
+ {
+ errors.Add("retentionDays must be >= 1");
+ }
+
+ if (config.BatchSize < 1 || config.BatchSize > 100_000)
+ {
+ errors.Add("batchSize must be between 1 and 100,000");
+ }
+
+ if (config.Schemas.Count == 0)
+ {
+ errors.Add("At least one schema must be specified");
+ }
+
+ return Task.FromResult(errors.Count == 0
+ ? JobConfigValidationResult.Valid
+ : JobConfigValidationResult.Invalid(errors.ToArray()));
+ }
+
+ ///
+ public string? GetConfigJsonSchema()
+ {
+ return """
+ {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "retentionDays": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 90,
+ "description": "Number of days to retain audit events before deletion"
+ },
+ "batchSize": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100000,
+ "default": 1000,
+ "description": "Maximum number of events to delete per batch"
+ },
+ "schemas": {
+ "type": "array",
+ "items": { "type": "string" },
+ "minItems": 1,
+ "default": ["timeline"],
+ "description": "PostgreSQL schemas to target for cleansing"
+ },
+ "dryRun": {
+ "type": "boolean",
+ "default": false,
+ "description": "When true, counts affected rows without deleting"
+ }
+ },
+ "required": ["retentionDays", "schemas"]
+ }
+ """;
+ }
+
+ ///
+ public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ // No additional DI registrations required for audit cleanse.
+ }
+
+ ///
+ public void MapEndpoints(IEndpointRouteBuilder routes)
+ {
+ // No additional endpoints for audit cleanse.
+ }
+
+ // ── Private helpers ────────────────────────────────────────────────
+
+ internal static AuditCleanseConfig ExtractConfig(IReadOnlyDictionary? pluginConfig)
+ {
+ if (pluginConfig is null)
+ {
+ return AuditCleanseConfig.Default;
+ }
+
+ var retentionDays = pluginConfig.TryGetValue("retentionDays", out var rd)
+ ? Convert.ToInt32(rd)
+ : 90;
+
+ var batchSize = pluginConfig.TryGetValue("batchSize", out var bs)
+ ? Convert.ToInt32(bs)
+ : 1000;
+
+ var schemas = pluginConfig.TryGetValue("schemas", out var s) && s is IReadOnlyList