Ops.DataGovernance -- Backup, Retention, GDPR, and Recovery
"When was the last backup?" "Uh... let me check."
The Problem
The incident started at 14:32 on a Tuesday. A migration script dropped a column instead of adding one. The column contained customer addresses. The team needed to restore from backup.
Nobody knew when the last backup ran. The backup was configured in a cron job on the old infrastructure. When the team migrated to Kubernetes three months ago, the cron job was not migrated. The last backup was from before the migration. Three months of customer data existed only in the production database that just lost a column.
The GDPR audit failed. The DPA (Data Protection Authority) asked for a Record of Processing Activities. Specifically: which entities contain personal data, what is the lawful basis for processing, and what is the retention period. The team spent two weeks grepping the codebase for [PersonalData] attributes (there were none) and manually building a spreadsheet. They found PII in 14 entities. They missed 3 more that the auditor found.
Recovery objectives existed only in the CTO's head. When asked "what is your RPO for the order database?", the CTO said "four hours." When asked where that was documented, the answer was "it's not." When asked whether the backup schedule actually achieved a four-hour RPO, the answer was "I think so." It did not. The backup ran daily. The RPO was 24 hours, not 4.
Retention policies were never enforced. GDPR Article 5(1)(e) requires that personal data be kept no longer than necessary. The team had a retention policy document that said "customer data is retained for 3 years after account closure." The database had customers who closed their accounts in 2019. Their data was still there. No job existed to enforce the retention policy.
Seed data was fragile. Every developer had their own SQL script to populate the local database. The scripts diverged. Integration tests assumed data that did not exist on the CI server. The test environment had production-like data because someone copied production to staging two years ago. That staging database still had real customer email addresses.
What is missing:
- Backup policies declared in code. Every database should declare its backup frequency, retention period, and storage location. The compiler should verify that every database has a backup policy.
- GDPR data maps attached to entities. If an entity contains PII, the data map should be declared on the entity class, not in a separate spreadsheet.
- Recovery objectives as typed values. RPO and RTO should be attributes on the database, not verbal commitments.
- Retention enforcement as generated background services. The retention policy and the job that enforces it should come from the same source.
- Seed data as declarative, ordered, environment-aware definitions. Not SQL scripts. Not manual inserts. Attributes.
Attribute Definitions
// =================================================================
// Ops.DataGovernance.Lib -- Data Governance DSL Attributes
// =================================================================
/// Classification level for data entities.
public enum ClassificationLevel
{
Public, // marketing pages, public API docs
Internal, // internal dashboards, non-sensitive configs
Confidential, // customer data, financial records
Restricted // PII, health records, payment card data
}
/// What happens when retention period expires.
public enum RetentionAction
{
Archive, // move to cold storage, keep accessible
Delete, // hard delete, irrecoverable
Anonymize, // replace PII with irreversible tokens
Pseudonymize // replace PII with reversible tokens (key kept separate)
}
/// GDPR lawful basis for processing personal data (Article 6).
public enum LawfulBasis
{
Consent, // data subject gave explicit consent
Contract, // processing necessary for contract performance
LegalObligation, // required by law (tax records, etc.)
VitalInterest, // protect someone's life
PublicTask, // public authority or public interest
LegitimateInterest // legitimate business interest (balancing test required)
}
/// GDPR deletion strategy when data subject exercises right to erasure.
public enum DeletionStrategy
{
HardDelete, // remove from all stores including backups
SoftDelete, // mark as deleted, purge after retention window
Anonymize, // replace identifying fields, keep statistical data
CascadeDelete // delete entity and all referencing entities
}
/// Declares backup policy for a database or data store.
[AttributeUsage(AttributeTargets.Class)]
public sealed class BackupPolicyAttribute : Attribute
{
public string Database { get; }
public string Frequency { get; }
public string Retention { get; }
public string StorageLocation { get; }
public bool EncryptAtRest { get; }
public BackupPolicyAttribute(
string database,
string frequency,
string retention,
string storageLocation,
bool encryptAtRest = true)
{
Database = database;
Frequency = frequency;
Retention = retention;
StorageLocation = storageLocation;
EncryptAtRest = encryptAtRest;
}
/// <summary>Backup type: Full, Differential, or Incremental.</summary>
public string BackupType { get; init; } = "Full";
/// <summary>Enable point-in-time recovery (WAL archiving).</summary>
public bool PointInTimeRecovery { get; init; } = false;
/// <summary>Compression algorithm. Null = no compression.</summary>
public string? Compression { get; init; } = "gzip";
}
/// Declares retention policy for a data entity.
[AttributeUsage(AttributeTargets.Class)]
public sealed class RetentionPolicyAttribute : Attribute
{
public Type Entity { get; }
public string MaxAge { get; }
public RetentionAction Action { get; }
public RetentionPolicyAttribute(
Type entity, string maxAge, RetentionAction action)
{
Entity = entity;
MaxAge = maxAge;
Action = action;
}
/// <summary>
/// Field that determines the entity's age (e.g., "ClosedAt", "CreatedAt").
/// </summary>
public string AgeField { get; init; } = "CreatedAt";
/// <summary>Grace period after expiry before action is taken.</summary>
public string GracePeriod { get; init; } = "0d";
/// <summary>Run retention job in dry-run mode first.</summary>
public bool DryRunFirst { get; init; } = true;
}
/// Maps an entity to GDPR processing record.
[AttributeUsage(AttributeTargets.Class)]
public sealed class GdprDataMapAttribute : Attribute
{
public Type Entity { get; }
public string[] PiiFields { get; }
public LawfulBasis LawfulBasis { get; }
public string RetentionPeriod { get; }
public DeletionStrategy DeletionStrategy { get; }
public GdprDataMapAttribute(
Type entity,
string[] piiFields,
LawfulBasis lawfulBasis,
string retentionPeriod,
DeletionStrategy deletionStrategy)
{
Entity = entity;
PiiFields = piiFields;
LawfulBasis = lawfulBasis;
RetentionPeriod = retentionPeriod;
DeletionStrategy = deletionStrategy;
}
/// <summary>Purpose of processing (required by GDPR Article 30).</summary>
public string Purpose { get; init; } = "";
/// <summary>Data controller name.</summary>
public string Controller { get; init; } = "";
/// <summary>Third-party processors with access to this data.</summary>
public string[]? Processors { get; init; }
/// <summary>Whether cross-border transfer occurs.</summary>
public bool CrossBorderTransfer { get; init; } = false;
/// <summary>Transfer mechanism (SCCs, BCRs, Adequacy Decision).</summary>
public string? TransferMechanism { get; init; }
}
/// Declares Recovery Point Objective and Recovery Time Objective.
[AttributeUsage(AttributeTargets.Class)]
public sealed class RecoveryObjectiveAttribute : Attribute
{
/// <summary>Maximum acceptable data loss (e.g., "4h", "1h", "15m").</summary>
public string RPO { get; }
/// <summary>Maximum acceptable downtime (e.g., "1h", "30m", "5m").</summary>
public string RTO { get; }
public RecoveryObjectiveAttribute(string rpo, string rto)
{
RPO = rpo;
RTO = rto;
}
/// <summary>
/// Whether automated failover is required to meet RTO.
/// </summary>
public bool AutomatedFailover { get; init; } = false;
/// <summary>Frequency of recovery drills (e.g., "quarterly").</summary>
public string DrillFrequency { get; init; } = "quarterly";
/// <summary>Notification channel for recovery events.</summary>
public string NotifyChannel { get; init; } = "#ops-incidents";
}
/// Declarative test/dev data seeding.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class SeedDataAttribute : Attribute
{
public string Name { get; }
public string[] Environments { get; }
public int Order { get; }
public SeedDataAttribute(
string name, string[] environments, int order)
{
Name = name;
Environments = environments;
Order = order;
}
/// <summary>Seed data source: embedded JSON, factory method, or SQL.</summary>
public string Source { get; init; } = "";
/// <summary>Whether to skip if data already exists.</summary>
public bool Idempotent { get; init; } = true;
/// <summary>Number of entities to generate (for factory sources).</summary>
public int Count { get; init; } = 1;
}
/// Classifies an entity's data sensitivity level.
[AttributeUsage(AttributeTargets.Class)]
public sealed class DataClassificationAttribute : Attribute
{
public Type Entity { get; }
public ClassificationLevel Level { get; }
public DataClassificationAttribute(
Type entity, ClassificationLevel level)
{
Entity = entity;
Level = level;
}
/// <summary>Justification for the classification level.</summary>
public string Justification { get; init; } = "";
/// <summary>Review date for reclassification.</summary>
public string ReviewDate { get; init; } = "";
}// =================================================================
// Ops.DataGovernance.Lib -- Data Governance DSL Attributes
// =================================================================
/// Classification level for data entities.
public enum ClassificationLevel
{
Public, // marketing pages, public API docs
Internal, // internal dashboards, non-sensitive configs
Confidential, // customer data, financial records
Restricted // PII, health records, payment card data
}
/// What happens when retention period expires.
public enum RetentionAction
{
Archive, // move to cold storage, keep accessible
Delete, // hard delete, irrecoverable
Anonymize, // replace PII with irreversible tokens
Pseudonymize // replace PII with reversible tokens (key kept separate)
}
/// GDPR lawful basis for processing personal data (Article 6).
public enum LawfulBasis
{
Consent, // data subject gave explicit consent
Contract, // processing necessary for contract performance
LegalObligation, // required by law (tax records, etc.)
VitalInterest, // protect someone's life
PublicTask, // public authority or public interest
LegitimateInterest // legitimate business interest (balancing test required)
}
/// GDPR deletion strategy when data subject exercises right to erasure.
public enum DeletionStrategy
{
HardDelete, // remove from all stores including backups
SoftDelete, // mark as deleted, purge after retention window
Anonymize, // replace identifying fields, keep statistical data
CascadeDelete // delete entity and all referencing entities
}
/// Declares backup policy for a database or data store.
[AttributeUsage(AttributeTargets.Class)]
public sealed class BackupPolicyAttribute : Attribute
{
public string Database { get; }
public string Frequency { get; }
public string Retention { get; }
public string StorageLocation { get; }
public bool EncryptAtRest { get; }
public BackupPolicyAttribute(
string database,
string frequency,
string retention,
string storageLocation,
bool encryptAtRest = true)
{
Database = database;
Frequency = frequency;
Retention = retention;
StorageLocation = storageLocation;
EncryptAtRest = encryptAtRest;
}
/// <summary>Backup type: Full, Differential, or Incremental.</summary>
public string BackupType { get; init; } = "Full";
/// <summary>Enable point-in-time recovery (WAL archiving).</summary>
public bool PointInTimeRecovery { get; init; } = false;
/// <summary>Compression algorithm. Null = no compression.</summary>
public string? Compression { get; init; } = "gzip";
}
/// Declares retention policy for a data entity.
[AttributeUsage(AttributeTargets.Class)]
public sealed class RetentionPolicyAttribute : Attribute
{
public Type Entity { get; }
public string MaxAge { get; }
public RetentionAction Action { get; }
public RetentionPolicyAttribute(
Type entity, string maxAge, RetentionAction action)
{
Entity = entity;
MaxAge = maxAge;
Action = action;
}
/// <summary>
/// Field that determines the entity's age (e.g., "ClosedAt", "CreatedAt").
/// </summary>
public string AgeField { get; init; } = "CreatedAt";
/// <summary>Grace period after expiry before action is taken.</summary>
public string GracePeriod { get; init; } = "0d";
/// <summary>Run retention job in dry-run mode first.</summary>
public bool DryRunFirst { get; init; } = true;
}
/// Maps an entity to GDPR processing record.
[AttributeUsage(AttributeTargets.Class)]
public sealed class GdprDataMapAttribute : Attribute
{
public Type Entity { get; }
public string[] PiiFields { get; }
public LawfulBasis LawfulBasis { get; }
public string RetentionPeriod { get; }
public DeletionStrategy DeletionStrategy { get; }
public GdprDataMapAttribute(
Type entity,
string[] piiFields,
LawfulBasis lawfulBasis,
string retentionPeriod,
DeletionStrategy deletionStrategy)
{
Entity = entity;
PiiFields = piiFields;
LawfulBasis = lawfulBasis;
RetentionPeriod = retentionPeriod;
DeletionStrategy = deletionStrategy;
}
/// <summary>Purpose of processing (required by GDPR Article 30).</summary>
public string Purpose { get; init; } = "";
/// <summary>Data controller name.</summary>
public string Controller { get; init; } = "";
/// <summary>Third-party processors with access to this data.</summary>
public string[]? Processors { get; init; }
/// <summary>Whether cross-border transfer occurs.</summary>
public bool CrossBorderTransfer { get; init; } = false;
/// <summary>Transfer mechanism (SCCs, BCRs, Adequacy Decision).</summary>
public string? TransferMechanism { get; init; }
}
/// Declares Recovery Point Objective and Recovery Time Objective.
[AttributeUsage(AttributeTargets.Class)]
public sealed class RecoveryObjectiveAttribute : Attribute
{
/// <summary>Maximum acceptable data loss (e.g., "4h", "1h", "15m").</summary>
public string RPO { get; }
/// <summary>Maximum acceptable downtime (e.g., "1h", "30m", "5m").</summary>
public string RTO { get; }
public RecoveryObjectiveAttribute(string rpo, string rto)
{
RPO = rpo;
RTO = rto;
}
/// <summary>
/// Whether automated failover is required to meet RTO.
/// </summary>
public bool AutomatedFailover { get; init; } = false;
/// <summary>Frequency of recovery drills (e.g., "quarterly").</summary>
public string DrillFrequency { get; init; } = "quarterly";
/// <summary>Notification channel for recovery events.</summary>
public string NotifyChannel { get; init; } = "#ops-incidents";
}
/// Declarative test/dev data seeding.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class SeedDataAttribute : Attribute
{
public string Name { get; }
public string[] Environments { get; }
public int Order { get; }
public SeedDataAttribute(
string name, string[] environments, int order)
{
Name = name;
Environments = environments;
Order = order;
}
/// <summary>Seed data source: embedded JSON, factory method, or SQL.</summary>
public string Source { get; init; } = "";
/// <summary>Whether to skip if data already exists.</summary>
public bool Idempotent { get; init; } = true;
/// <summary>Number of entities to generate (for factory sources).</summary>
public int Count { get; init; } = 1;
}
/// Classifies an entity's data sensitivity level.
[AttributeUsage(AttributeTargets.Class)]
public sealed class DataClassificationAttribute : Attribute
{
public Type Entity { get; }
public ClassificationLevel Level { get; }
public DataClassificationAttribute(
Type entity, ClassificationLevel level)
{
Entity = entity;
Level = level;
}
/// <summary>Justification for the classification level.</summary>
public string Justification { get; init; } = "";
/// <summary>Review date for reclassification.</summary>
public string ReviewDate { get; init; } = "";
}Usage: The OrderService Data Governance Declaration
[DeploymentApp("order-service")]
// Backup: every 4 hours, keep for 30 days, encrypted, with PITR
[BackupPolicy(
database: "orders-db",
frequency: "4h",
retention: "30d",
storageLocation: "s3://backups-eu-west-1/orders",
encryptAtRest: true,
PointInTimeRecovery = true,
Compression = "zstd")]
// Recovery: 4-hour RPO (matches backup frequency), 30-minute RTO
[RecoveryObjective(
rpo: "4h",
rto: "30m",
AutomatedFailover = true,
DrillFrequency = "quarterly",
NotifyChannel = "#orders-ops")]
// Retention: archive closed orders after 7 years (legal obligation)
[RetentionPolicy(
typeof(Order), "7y", RetentionAction.Archive,
AgeField = "ClosedAt",
DryRunFirst = true)]
// Retention: anonymize inactive customers after 3 years
[RetentionPolicy(
typeof(Customer), "3y", RetentionAction.Anonymize,
AgeField = "LastActivityAt",
GracePeriod = "30d")]
// GDPR: Customer entity contains PII
[GdprDataMap(
typeof(Customer),
piiFields: new[] { "Email", "FullName", "Phone", "Address" },
LawfulBasis.Contract,
retentionPeriod: "3y",
DeletionStrategy.Anonymize,
Purpose = "Order fulfillment and customer communication",
Controller = "Acme Corp",
Processors = new[] { "Stripe", "SendGrid" },
CrossBorderTransfer = true,
TransferMechanism = "SCCs")]
// GDPR: Order entity contains billing PII
[GdprDataMap(
typeof(Order),
piiFields: new[] { "BillingAddress", "BillingName" },
LawfulBasis.Contract,
retentionPeriod: "7y",
DeletionStrategy.CascadeDelete,
Purpose = "Financial record keeping")]
// Classification
[DataClassification(typeof(Customer), ClassificationLevel.Restricted,
Justification = "Contains directly identifiable PII")]
[DataClassification(typeof(Order), ClassificationLevel.Confidential,
Justification = "Contains billing information")]
[DataClassification(typeof(ProductCatalog), ClassificationLevel.Internal)]
// Seed data
[SeedData("test-customers", new[] { "Development", "Test" }, Order = 1,
Source = "SeedFactories.CreateCustomers", Count = 50, Idempotent = true)]
[SeedData("test-orders", new[] { "Development", "Test" }, Order = 2,
Source = "SeedFactories.CreateOrders", Count = 200, Idempotent = true)]
[SeedData("demo-data", new[] { "Staging" }, Order = 1,
Source = "Seeds/demo-data.json", Idempotent = true)]
public partial class OrderServiceGovernance { }[DeploymentApp("order-service")]
// Backup: every 4 hours, keep for 30 days, encrypted, with PITR
[BackupPolicy(
database: "orders-db",
frequency: "4h",
retention: "30d",
storageLocation: "s3://backups-eu-west-1/orders",
encryptAtRest: true,
PointInTimeRecovery = true,
Compression = "zstd")]
// Recovery: 4-hour RPO (matches backup frequency), 30-minute RTO
[RecoveryObjective(
rpo: "4h",
rto: "30m",
AutomatedFailover = true,
DrillFrequency = "quarterly",
NotifyChannel = "#orders-ops")]
// Retention: archive closed orders after 7 years (legal obligation)
[RetentionPolicy(
typeof(Order), "7y", RetentionAction.Archive,
AgeField = "ClosedAt",
DryRunFirst = true)]
// Retention: anonymize inactive customers after 3 years
[RetentionPolicy(
typeof(Customer), "3y", RetentionAction.Anonymize,
AgeField = "LastActivityAt",
GracePeriod = "30d")]
// GDPR: Customer entity contains PII
[GdprDataMap(
typeof(Customer),
piiFields: new[] { "Email", "FullName", "Phone", "Address" },
LawfulBasis.Contract,
retentionPeriod: "3y",
DeletionStrategy.Anonymize,
Purpose = "Order fulfillment and customer communication",
Controller = "Acme Corp",
Processors = new[] { "Stripe", "SendGrid" },
CrossBorderTransfer = true,
TransferMechanism = "SCCs")]
// GDPR: Order entity contains billing PII
[GdprDataMap(
typeof(Order),
piiFields: new[] { "BillingAddress", "BillingName" },
LawfulBasis.Contract,
retentionPeriod: "7y",
DeletionStrategy.CascadeDelete,
Purpose = "Financial record keeping")]
// Classification
[DataClassification(typeof(Customer), ClassificationLevel.Restricted,
Justification = "Contains directly identifiable PII")]
[DataClassification(typeof(Order), ClassificationLevel.Confidential,
Justification = "Contains billing information")]
[DataClassification(typeof(ProductCatalog), ClassificationLevel.Internal)]
// Seed data
[SeedData("test-customers", new[] { "Development", "Test" }, Order = 1,
Source = "SeedFactories.CreateCustomers", Count = 50, Idempotent = true)]
[SeedData("test-orders", new[] { "Development", "Test" }, Order = 2,
Source = "SeedFactories.CreateOrders", Count = 200, Idempotent = true)]
[SeedData("demo-data", new[] { "Staging" }, Order = 1,
Source = "Seeds/demo-data.json", Idempotent = true)]
public partial class OrderServiceGovernance { }One class. Six concerns. Every one of them was previously either undocumented, in a separate system, or forgotten entirely.
InProcess Tier
The InProcess tier handles three things: seed data execution, retention job simulation, and GDPR data map validation.
Seed data execution. The generator emits a SeedDataRunner that the DI container resolves at startup. It reads the [SeedData] attributes, filters by the current environment (ASPNETCORE_ENVIRONMENT), sorts by Order, and executes each seed source. Factory sources call the specified static method. JSON sources deserialize from embedded resources.
Retention job simulation. The RetentionJob runs as a BackgroundService. In the InProcess tier, it does not delete or archive anything. Instead, it queries the database, identifies entities that would be affected by the retention policy, and logs the count. This lets developers see "RetentionJob: 47 Customer entities would be anonymized (dry run)" in the console during development.
GDPR data map validation. At startup, the generated GdprDataMapValidator verifies that every field listed in PiiFields actually exists on the entity type via reflection. If a developer renames Customer.Email to Customer.EmailAddress but forgets to update the [GdprDataMap], the application fails to start with a clear error message.
Container Tier
The Container tier runs real backup and restore operations against a containerized PostgreSQL instance.
The generator produces a docker-compose.override.yml that adds a backup sidecar container running pg_dump on the declared frequency. The restore test runs as part of the integration test suite: it takes the latest backup, restores it to a second container, and verifies row counts match.
For retention, the actual retention job runs against the containerized database. It deletes, archives, or anonymizes real rows, verifying that the retention logic works correctly before it touches production.
Cloud Tier
The Cloud tier generates production-grade infrastructure.
Backup policies become Terraform resources: aws_db_instance with backup_retention_period and backup_window, or azurerm_postgresql_flexible_server with backup_retention_days. The RecoveryObjective attributes validate that the backup frequency is sufficient to meet the declared RPO.
Cross-region replication is configured when AutomatedFailover = true. The generator emits the replica configuration, the failover route, and the health check that triggers failover.
InProcess: SeedDataRunner.g.cs
// <auto-generated by Ops.DataGovernance.Generator />
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace OrderService.Generated;
public sealed class SeedDataRunner : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _env;
private readonly ILogger<SeedDataRunner> _logger;
public SeedDataRunner(
IServiceProvider services,
IHostEnvironment env,
ILogger<SeedDataRunner> logger)
{
_services = services;
_env = env;
_logger = logger;
}
public async Task StartAsync(CancellationToken ct)
{
var seeds = new (string Name, string[] Envs, int Order, Func<IServiceProvider, CancellationToken, Task> Execute)[]
{
("test-customers",
new[] { "Development", "Test" }, 1,
async (sp, c) =>
{
var db = sp.GetRequiredService<OrderDbContext>();
if (await db.Customers.AnyAsync(c)) return; // idempotent
var entities = SeedFactories.CreateCustomers(50);
db.Customers.AddRange(entities);
await db.SaveChangesAsync(c);
}),
("test-orders",
new[] { "Development", "Test" }, 2,
async (sp, c) =>
{
var db = sp.GetRequiredService<OrderDbContext>();
if (await db.Orders.AnyAsync(c)) return;
var entities = SeedFactories.CreateOrders(200);
db.Orders.AddRange(entities);
await db.SaveChangesAsync(c);
}),
("demo-data",
new[] { "Staging" }, 1,
async (sp, c) =>
{
var db = sp.GetRequiredService<OrderDbContext>();
await SeedFromJson.ExecuteAsync(
db, "Seeds/demo-data.json", c);
}),
};
var applicable = seeds
.Where(s => s.Envs.Contains(_env.EnvironmentName))
.OrderBy(s => s.Order);
foreach (var seed in applicable)
{
_logger.LogInformation(
"Executing seed '{Name}' (order {Order})",
seed.Name, seed.Order);
await seed.Execute(_services, ct);
}
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}// <auto-generated by Ops.DataGovernance.Generator />
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace OrderService.Generated;
public sealed class SeedDataRunner : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _env;
private readonly ILogger<SeedDataRunner> _logger;
public SeedDataRunner(
IServiceProvider services,
IHostEnvironment env,
ILogger<SeedDataRunner> logger)
{
_services = services;
_env = env;
_logger = logger;
}
public async Task StartAsync(CancellationToken ct)
{
var seeds = new (string Name, string[] Envs, int Order, Func<IServiceProvider, CancellationToken, Task> Execute)[]
{
("test-customers",
new[] { "Development", "Test" }, 1,
async (sp, c) =>
{
var db = sp.GetRequiredService<OrderDbContext>();
if (await db.Customers.AnyAsync(c)) return; // idempotent
var entities = SeedFactories.CreateCustomers(50);
db.Customers.AddRange(entities);
await db.SaveChangesAsync(c);
}),
("test-orders",
new[] { "Development", "Test" }, 2,
async (sp, c) =>
{
var db = sp.GetRequiredService<OrderDbContext>();
if (await db.Orders.AnyAsync(c)) return;
var entities = SeedFactories.CreateOrders(200);
db.Orders.AddRange(entities);
await db.SaveChangesAsync(c);
}),
("demo-data",
new[] { "Staging" }, 1,
async (sp, c) =>
{
var db = sp.GetRequiredService<OrderDbContext>();
await SeedFromJson.ExecuteAsync(
db, "Seeds/demo-data.json", c);
}),
};
var applicable = seeds
.Where(s => s.Envs.Contains(_env.EnvironmentName))
.OrderBy(s => s.Order);
foreach (var seed in applicable)
{
_logger.LogInformation(
"Executing seed '{Name}' (order {Order})",
seed.Name, seed.Order);
await seed.Execute(_services, ct);
}
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}InProcess: RetentionJob.g.cs
// <auto-generated by Ops.DataGovernance.Generator />
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace OrderService.Generated;
public sealed class RetentionJob : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<RetentionJob> _logger;
private readonly TimeSpan _interval = TimeSpan.FromHours(24);
public RetentionJob(
IServiceProvider services,
ILogger<RetentionJob> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await RunRetentionCycle(ct);
await Task.Delay(_interval, ct);
}
}
private async Task RunRetentionCycle(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<OrderDbContext>();
var clock = scope.ServiceProvider
.GetRequiredService<TimeProvider>();
var now = clock.GetUtcNow();
// Policy: Customer — 3y from LastActivityAt — Anonymize
// Grace period: 30d
var customerCutoff = now
.AddYears(-3)
.AddDays(-30);
var customersToAnonymize = await db.Customers
.Where(c => c.LastActivityAt < customerCutoff)
.Where(c => !c.IsAnonymized)
.ToListAsync(ct);
_logger.LogInformation(
"RetentionJob: {Count} Customer entities eligible " +
"for anonymization", customersToAnonymize.Count);
foreach (var customer in customersToAnonymize)
{
customer.Email = $"anon-{customer.Id}@redacted.local";
customer.FullName = "[REDACTED]";
customer.Phone = null;
customer.Address = null;
customer.IsAnonymized = true;
}
// Policy: Order — 7y from ClosedAt — Archive
var orderCutoff = now.AddYears(-7);
var ordersToArchive = await db.Orders
.Where(o => o.ClosedAt != null && o.ClosedAt < orderCutoff)
.Where(o => !o.IsArchived)
.ToListAsync(ct);
_logger.LogInformation(
"RetentionJob: {Count} Order entities eligible " +
"for archival", ordersToArchive.Count);
foreach (var order in ordersToArchive)
{
await ArchiveToStorage(order, ct);
order.IsArchived = true;
}
await db.SaveChangesAsync(ct);
}
private Task ArchiveToStorage(Order order, CancellationToken ct)
{
// Generated: serialize to archive store (S3, blob, etc.)
return Task.CompletedTask;
}
}// <auto-generated by Ops.DataGovernance.Generator />
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace OrderService.Generated;
public sealed class RetentionJob : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<RetentionJob> _logger;
private readonly TimeSpan _interval = TimeSpan.FromHours(24);
public RetentionJob(
IServiceProvider services,
ILogger<RetentionJob> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await RunRetentionCycle(ct);
await Task.Delay(_interval, ct);
}
}
private async Task RunRetentionCycle(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<OrderDbContext>();
var clock = scope.ServiceProvider
.GetRequiredService<TimeProvider>();
var now = clock.GetUtcNow();
// Policy: Customer — 3y from LastActivityAt — Anonymize
// Grace period: 30d
var customerCutoff = now
.AddYears(-3)
.AddDays(-30);
var customersToAnonymize = await db.Customers
.Where(c => c.LastActivityAt < customerCutoff)
.Where(c => !c.IsAnonymized)
.ToListAsync(ct);
_logger.LogInformation(
"RetentionJob: {Count} Customer entities eligible " +
"for anonymization", customersToAnonymize.Count);
foreach (var customer in customersToAnonymize)
{
customer.Email = $"anon-{customer.Id}@redacted.local";
customer.FullName = "[REDACTED]";
customer.Phone = null;
customer.Address = null;
customer.IsAnonymized = true;
}
// Policy: Order — 7y from ClosedAt — Archive
var orderCutoff = now.AddYears(-7);
var ordersToArchive = await db.Orders
.Where(o => o.ClosedAt != null && o.ClosedAt < orderCutoff)
.Where(o => !o.IsArchived)
.ToListAsync(ct);
_logger.LogInformation(
"RetentionJob: {Count} Order entities eligible " +
"for archival", ordersToArchive.Count);
foreach (var order in ordersToArchive)
{
await ArchiveToStorage(order, ct);
order.IsArchived = true;
}
await db.SaveChangesAsync(ct);
}
private Task ArchiveToStorage(Order order, CancellationToken ct)
{
// Generated: serialize to archive store (S3, blob, etc.)
return Task.CompletedTask;
}
}InProcess: GdprDataMap.g.cs
// <auto-generated by Ops.DataGovernance.Generator />
namespace OrderService.Generated;
/// <summary>
/// Registry of all PII-containing entities and their processing records.
/// Generated from [GdprDataMap] attributes.
/// </summary>
public static class GdprDataMap
{
public static readonly IReadOnlyList<GdprProcessingRecord> Records =
new GdprProcessingRecord[]
{
new(
EntityType: typeof(Customer),
PiiFields: new[] { "Email", "FullName", "Phone", "Address" },
LawfulBasis: "Contract",
RetentionPeriod: "3y",
DeletionStrategy: "Anonymize",
Purpose: "Order fulfillment and customer communication",
Controller: "Acme Corp",
Processors: new[] { "Stripe", "SendGrid" },
CrossBorderTransfer: true,
TransferMechanism: "SCCs"),
new(
EntityType: typeof(Order),
PiiFields: new[] { "BillingAddress", "BillingName" },
LawfulBasis: "Contract",
RetentionPeriod: "7y",
DeletionStrategy: "CascadeDelete",
Purpose: "Financial record keeping",
Controller: "",
Processors: Array.Empty<string>(),
CrossBorderTransfer: false,
TransferMechanism: null),
};
/// <summary>
/// Validates that declared PII fields exist on entity types.
/// Called at startup.
/// </summary>
public static void Validate()
{
var errors = new List<string>();
foreach (var record in Records)
{
foreach (var field in record.PiiFields)
{
var prop = record.EntityType.GetProperty(field);
if (prop is null)
{
errors.Add(
$"GdprDataMap: Entity '{record.EntityType.Name}' " +
$"declares PII field '{field}' but no such " +
$"property exists.");
}
}
}
if (errors.Count > 0)
{
throw new InvalidOperationException(
"GDPR data map validation failed:\n" +
string.Join("\n", errors));
}
}
}
public sealed record GdprProcessingRecord(
Type EntityType,
string[] PiiFields,
string LawfulBasis,
string RetentionPeriod,
string DeletionStrategy,
string Purpose,
string Controller,
string[] Processors,
bool CrossBorderTransfer,
string? TransferMechanism);// <auto-generated by Ops.DataGovernance.Generator />
namespace OrderService.Generated;
/// <summary>
/// Registry of all PII-containing entities and their processing records.
/// Generated from [GdprDataMap] attributes.
/// </summary>
public static class GdprDataMap
{
public static readonly IReadOnlyList<GdprProcessingRecord> Records =
new GdprProcessingRecord[]
{
new(
EntityType: typeof(Customer),
PiiFields: new[] { "Email", "FullName", "Phone", "Address" },
LawfulBasis: "Contract",
RetentionPeriod: "3y",
DeletionStrategy: "Anonymize",
Purpose: "Order fulfillment and customer communication",
Controller: "Acme Corp",
Processors: new[] { "Stripe", "SendGrid" },
CrossBorderTransfer: true,
TransferMechanism: "SCCs"),
new(
EntityType: typeof(Order),
PiiFields: new[] { "BillingAddress", "BillingName" },
LawfulBasis: "Contract",
RetentionPeriod: "7y",
DeletionStrategy: "CascadeDelete",
Purpose: "Financial record keeping",
Controller: "",
Processors: Array.Empty<string>(),
CrossBorderTransfer: false,
TransferMechanism: null),
};
/// <summary>
/// Validates that declared PII fields exist on entity types.
/// Called at startup.
/// </summary>
public static void Validate()
{
var errors = new List<string>();
foreach (var record in Records)
{
foreach (var field in record.PiiFields)
{
var prop = record.EntityType.GetProperty(field);
if (prop is null)
{
errors.Add(
$"GdprDataMap: Entity '{record.EntityType.Name}' " +
$"declares PII field '{field}' but no such " +
$"property exists.");
}
}
}
if (errors.Count > 0)
{
throw new InvalidOperationException(
"GDPR data map validation failed:\n" +
string.Join("\n", errors));
}
}
}
public sealed record GdprProcessingRecord(
Type EntityType,
string[] PiiFields,
string LawfulBasis,
string RetentionPeriod,
string DeletionStrategy,
string Purpose,
string Controller,
string[] Processors,
bool CrossBorderTransfer,
string? TransferMechanism);Container: backup-cronjob.yaml
# auto-generated by Ops.DataGovernance.Generator
# Source: OrderServiceGovernance [BackupPolicy]
apiVersion: batch/v1
kind: CronJob
metadata:
name: orders-db-backup
namespace: order-service
labels:
app.kubernetes.io/component: backup
ops.dsl/generator: data-governance
spec:
schedule: "0 */4 * * *" # every 4 hours
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backupLimit: 1
template:
spec:
restartPolicy: OnFailure
containers:
- name: pg-backup
image: postgres:16-alpine
command:
- /bin/sh
- -c
- |
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
FILENAME="orders-db-${TIMESTAMP}.sql.zst"
pg_dump "$DATABASE_URL" \
| zstd -19 \
| aws s3 cp - \
"s3://backups-eu-west-1/orders/${FILENAME}" \
--sse aws:kms
echo "Backup complete: ${FILENAME}"
envFrom:
- secretRef:
name: orders-db-credentials
env:
- name: AWS_DEFAULT_REGION
value: eu-west-1# auto-generated by Ops.DataGovernance.Generator
# Source: OrderServiceGovernance [BackupPolicy]
apiVersion: batch/v1
kind: CronJob
metadata:
name: orders-db-backup
namespace: order-service
labels:
app.kubernetes.io/component: backup
ops.dsl/generator: data-governance
spec:
schedule: "0 */4 * * *" # every 4 hours
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backupLimit: 1
template:
spec:
restartPolicy: OnFailure
containers:
- name: pg-backup
image: postgres:16-alpine
command:
- /bin/sh
- -c
- |
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
FILENAME="orders-db-${TIMESTAMP}.sql.zst"
pg_dump "$DATABASE_URL" \
| zstd -19 \
| aws s3 cp - \
"s3://backups-eu-west-1/orders/${FILENAME}" \
--sse aws:kms
echo "Backup complete: ${FILENAME}"
envFrom:
- secretRef:
name: orders-db-credentials
env:
- name: AWS_DEFAULT_REGION
value: eu-west-1Container: restore-test.yaml
# auto-generated by Ops.DataGovernance.Generator
# Source: OrderServiceGovernance [RecoveryObjective]
# Validates: RPO=4h, RTO=30m
apiVersion: batch/v1
kind: Job
metadata:
name: orders-db-restore-test
namespace: order-service-test
labels:
ops.dsl/generator: data-governance
ops.dsl/test-type: recovery-drill
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: fetch-backup
image: amazon/aws-cli:2
command:
- /bin/sh
- -c
- |
LATEST=$(aws s3 ls s3://backups-eu-west-1/orders/ \
| sort | tail -1 | awk '{print $4}')
aws s3 cp \
"s3://backups-eu-west-1/orders/${LATEST}" \
/backup/latest.sql.zst
# Verify backup age meets RPO (4h)
BACKUP_TS=$(echo "$LATEST" \
| grep -oP '\d{8}-\d{6}')
# Fail if backup is older than RPO
volumeMounts:
- name: backup-vol
mountPath: /backup
containers:
- name: restore-verify
image: postgres:16-alpine
command:
- /bin/sh
- -c
- |
set -euo pipefail
START=$(date +%s)
# Restore
zstd -d /backup/latest.sql.zst -o /backup/latest.sql
createdb -h localhost -U postgres restore_test
psql -h localhost -U postgres -d restore_test \
-f /backup/latest.sql
END=$(date +%s)
ELAPSED=$((END - START))
echo "Restore completed in ${ELAPSED}s"
# RTO check: must complete within 1800s (30m)
if [ "$ELAPSED" -gt 1800 ]; then
echo "FAIL: Restore took ${ELAPSED}s, RTO is 1800s"
exit 1
fi
# Verify data integrity
psql -h localhost -U postgres -d restore_test \
-c "SELECT count(*) FROM customers;" \
-c "SELECT count(*) FROM orders;"
volumeMounts:
- name: backup-vol
mountPath: /backup
volumes:
- name: backup-vol
emptyDir: {}# auto-generated by Ops.DataGovernance.Generator
# Source: OrderServiceGovernance [RecoveryObjective]
# Validates: RPO=4h, RTO=30m
apiVersion: batch/v1
kind: Job
metadata:
name: orders-db-restore-test
namespace: order-service-test
labels:
ops.dsl/generator: data-governance
ops.dsl/test-type: recovery-drill
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: fetch-backup
image: amazon/aws-cli:2
command:
- /bin/sh
- -c
- |
LATEST=$(aws s3 ls s3://backups-eu-west-1/orders/ \
| sort | tail -1 | awk '{print $4}')
aws s3 cp \
"s3://backups-eu-west-1/orders/${LATEST}" \
/backup/latest.sql.zst
# Verify backup age meets RPO (4h)
BACKUP_TS=$(echo "$LATEST" \
| grep -oP '\d{8}-\d{6}')
# Fail if backup is older than RPO
volumeMounts:
- name: backup-vol
mountPath: /backup
containers:
- name: restore-verify
image: postgres:16-alpine
command:
- /bin/sh
- -c
- |
set -euo pipefail
START=$(date +%s)
# Restore
zstd -d /backup/latest.sql.zst -o /backup/latest.sql
createdb -h localhost -U postgres restore_test
psql -h localhost -U postgres -d restore_test \
-f /backup/latest.sql
END=$(date +%s)
ELAPSED=$((END - START))
echo "Restore completed in ${ELAPSED}s"
# RTO check: must complete within 1800s (30m)
if [ "$ELAPSED" -gt 1800 ]; then
echo "FAIL: Restore took ${ELAPSED}s, RTO is 1800s"
exit 1
fi
# Verify data integrity
psql -h localhost -U postgres -d restore_test \
-c "SELECT count(*) FROM customers;" \
-c "SELECT count(*) FROM orders;"
volumeMounts:
- name: backup-vol
mountPath: /backup
volumes:
- name: backup-vol
emptyDir: {}Cloud: terraform/backup/main.tf
# auto-generated by Ops.DataGovernance.Generator
# Source: OrderServiceGovernance
# --- Backup Configuration ---
resource "aws_db_instance" "orders_db" {
# ... existing resource, these are the governance-relevant settings:
backup_retention_period = 30 # 30d retention
backup_window = "02:00-02:30" # UTC
copy_tags_to_snapshot = true
# Point-in-time recovery
# WAL archiving enabled via backup_retention_period > 0
# Encryption at rest
storage_encrypted = true
kms_key_id = aws_kms_key.orders_backup.arn
tags = {
"ops.dsl/backup-frequency" = "4h"
"ops.dsl/retention" = "30d"
"ops.dsl/rpo" = "4h"
"ops.dsl/rto" = "30m"
"ops.dsl/data-classification" = "Restricted"
}
}
# Cross-region replica for automated failover (RTO=30m)
resource "aws_db_instance" "orders_db_replica" {
replicate_source_db = aws_db_instance.orders_db.identifier
instance_class = aws_db_instance.orders_db.instance_class
storage_encrypted = true
kms_key_id = aws_kms_key.orders_backup_dr.arn
availability_zone = "eu-west-2a" # different region
tags = {
"ops.dsl/purpose" = "disaster-recovery"
"ops.dsl/rto" = "30m"
}
}
resource "aws_kms_key" "orders_backup" {
description = "Encryption key for orders-db backups"
deletion_window_in_days = 30
enable_key_rotation = true
}
resource "aws_kms_key" "orders_backup_dr" {
provider = aws.eu-west-2
description = "Encryption key for orders-db DR replica"
deletion_window_in_days = 30
enable_key_rotation = true
}
# --- Automated backup verification ---
resource "aws_cloudwatch_event_rule" "backup_drill" {
name = "orders-db-recovery-drill"
description = "Quarterly recovery drill (RecoveryObjective)"
schedule_expression = "rate(90 days)"
}
resource "aws_cloudwatch_event_target" "backup_drill_target" {
rule = aws_cloudwatch_event_rule.backup_drill.name
target_id = "orders-db-restore-test"
arn = aws_lambda_function.restore_drill.arn
}# auto-generated by Ops.DataGovernance.Generator
# Source: OrderServiceGovernance
# --- Backup Configuration ---
resource "aws_db_instance" "orders_db" {
# ... existing resource, these are the governance-relevant settings:
backup_retention_period = 30 # 30d retention
backup_window = "02:00-02:30" # UTC
copy_tags_to_snapshot = true
# Point-in-time recovery
# WAL archiving enabled via backup_retention_period > 0
# Encryption at rest
storage_encrypted = true
kms_key_id = aws_kms_key.orders_backup.arn
tags = {
"ops.dsl/backup-frequency" = "4h"
"ops.dsl/retention" = "30d"
"ops.dsl/rpo" = "4h"
"ops.dsl/rto" = "30m"
"ops.dsl/data-classification" = "Restricted"
}
}
# Cross-region replica for automated failover (RTO=30m)
resource "aws_db_instance" "orders_db_replica" {
replicate_source_db = aws_db_instance.orders_db.identifier
instance_class = aws_db_instance.orders_db.instance_class
storage_encrypted = true
kms_key_id = aws_kms_key.orders_backup_dr.arn
availability_zone = "eu-west-2a" # different region
tags = {
"ops.dsl/purpose" = "disaster-recovery"
"ops.dsl/rto" = "30m"
}
}
resource "aws_kms_key" "orders_backup" {
description = "Encryption key for orders-db backups"
deletion_window_in_days = 30
enable_key_rotation = true
}
resource "aws_kms_key" "orders_backup_dr" {
provider = aws.eu-west-2
description = "Encryption key for orders-db DR replica"
deletion_window_in_days = 30
enable_key_rotation = true
}
# --- Automated backup verification ---
resource "aws_cloudwatch_event_rule" "backup_drill" {
name = "orders-db-recovery-drill"
description = "Quarterly recovery drill (RecoveryObjective)"
schedule_expression = "rate(90 days)"
}
resource "aws_cloudwatch_event_target" "backup_drill_target" {
rule = aws_cloudwatch_event_rule.backup_drill.name
target_id = "orders-db-restore-test"
arn = aws_lambda_function.restore_drill.arn
}Cloud: gdpr-data-map.md (Auto-generated Compliance Documentation)
The generator also produces a markdown document suitable for handing directly to a DPA auditor:
# GDPR Record of Processing Activities
Generated: 2026-04-06T10:00:00Z
Source: OrderServiceGovernance
## Customer
| Field | PII | Lawful Basis | Retention | Deletion Strategy |
|----------------|-----|-------------|-----------|-------------------|
| Email | Yes | Contract | 3 years | Anonymize |
| FullName | Yes | Contract | 3 years | Anonymize |
| Phone | Yes | Contract | 3 years | Anonymize |
| Address | Yes | Contract | 3 years | Anonymize |
- **Purpose:** Order fulfillment and customer communication
- **Controller:** Acme Corp
- **Processors:** Stripe, SendGrid
- **Cross-border Transfer:** Yes (Standard Contractual Clauses)
- **Classification:** Restricted
## Order
| Field | PII | Lawful Basis | Retention | Deletion Strategy |
|----------------|-----|-------------|-----------|-------------------|
| BillingAddress | Yes | Contract | 7 years | Cascade Delete |
| BillingName | Yes | Contract | 7 years | Cascade Delete |
- **Purpose:** Financial record keeping
- **Classification:** Confidential# GDPR Record of Processing Activities
Generated: 2026-04-06T10:00:00Z
Source: OrderServiceGovernance
## Customer
| Field | PII | Lawful Basis | Retention | Deletion Strategy |
|----------------|-----|-------------|-----------|-------------------|
| Email | Yes | Contract | 3 years | Anonymize |
| FullName | Yes | Contract | 3 years | Anonymize |
| Phone | Yes | Contract | 3 years | Anonymize |
| Address | Yes | Contract | 3 years | Anonymize |
- **Purpose:** Order fulfillment and customer communication
- **Controller:** Acme Corp
- **Processors:** Stripe, SendGrid
- **Cross-border Transfer:** Yes (Standard Contractual Clauses)
- **Classification:** Restricted
## Order
| Field | PII | Lawful Basis | Retention | Deletion Strategy |
|----------------|-----|-------------|-----------|-------------------|
| BillingAddress | Yes | Contract | 7 years | Cascade Delete |
| BillingName | Yes | Contract | 7 years | Cascade Delete |
- **Purpose:** Financial record keeping
- **Classification:** ConfidentialThis document is regenerated on every build. It is always current. The auditor gets a document that matches the code, because it is generated from the code.
Analyzers
The DataGovernance analyzer runs at compile time and produces diagnostics for governance gaps.
| ID | Severity | Rule |
|---|---|---|
| DGV001 | Warning | Entity has PII fields but no [GdprDataMap] |
| DGV002 | Error | Database referenced in [DeploymentApp] but no [BackupPolicy] |
| DGV003 | Error | Production database without [RecoveryObjective] |
| DGV004 | Warning | Retention period exceeds GDPR maximum for lawful basis |
DGV001 -- PII Entity Without GDPR Data Map
The analyzer scans all entity types for properties with common PII patterns: Email, Phone, Address, Name, SSN, DateOfBirth, NationalId. If an entity has two or more such properties and no corresponding [GdprDataMap], it emits DGV001.
warning DGV001: Entity 'PaymentMethod' has properties ['CardholderName',
'BillingAddress'] that appear to contain PII but no [GdprDataMap] is
declared. Add [GdprDataMap(typeof(PaymentMethod), ...)] to your governance
class.warning DGV001: Entity 'PaymentMethod' has properties ['CardholderName',
'BillingAddress'] that appear to contain PII but no [GdprDataMap] is
declared. Add [GdprDataMap(typeof(PaymentMethod), ...)] to your governance
class.DGV002 -- Database Without Backup Policy
Every database name referenced in a [BackupPolicy], [ConnectionString], or database context registration is tracked. If a database exists without a corresponding [BackupPolicy], the analyzer emits DGV002 as an error.
error DGV002: Database 'analytics-db' is referenced by AnalyticsDbContext
but has no [BackupPolicy]. Add [BackupPolicy("analytics-db", ...)] to your
governance class.error DGV002: Database 'analytics-db' is referenced by AnalyticsDbContext
but has no [BackupPolicy]. Add [BackupPolicy("analytics-db", ...)] to your
governance class.DGV003 -- No Recovery Objective for Production Database
If a [BackupPolicy] exists but no [RecoveryObjective] is declared, the analyzer emits DGV003. This catches the "we have backups but no recovery plan" scenario.
error DGV003: Database 'orders-db' has [BackupPolicy] but no
[RecoveryObjective]. Declare RPO and RTO with
[RecoveryObjective(rpo: "...", rto: "...")].error DGV003: Database 'orders-db' has [BackupPolicy] but no
[RecoveryObjective]. Declare RPO and RTO with
[RecoveryObjective(rpo: "...", rto: "...")].DGV004 -- Retention Period Exceeds GDPR Maximum
Certain lawful bases have implied maximum retention periods. For example, Consent can be withdrawn at any time, so a retention period of "10y" with LawfulBasis.Consent is suspicious. The analyzer does not hard-fail on this -- it is a warning -- but it forces the team to justify long retention periods.
warning DGV004: Entity 'NewsletterSubscription' uses LawfulBasis.Consent
with retention period '10y'. Consent-based processing typically requires
shorter retention. Consider reducing retention or changing lawful basis.warning DGV004: Entity 'NewsletterSubscription' uses LawfulBasis.Consent
with retention period '10y'. Consent-based processing typically requires
shorter retention. Consider reducing retention or changing lawful basis.DataGovernance --> Security
Every entity with a [GdprDataMap] automatically requires an [AuditPolicy] from the Security DSL. The cross-DSL analyzer emits a warning if a PII-containing entity is not audited:
warning DGV-SEC001: Entity 'Customer' has [GdprDataMap] with PII fields
but no [AuditPolicy] from Ops.Security. GDPR Article 5(1)(f) requires
appropriate security, including audit trails for PII access.warning DGV-SEC001: Entity 'Customer' has [GdprDataMap] with PII fields
but no [AuditPolicy] from Ops.Security. GDPR Article 5(1)(f) requires
appropriate security, including audit trails for PII access.The generated GdprDataMap.g.cs exposes a GetPiiEntities() method that the Security DSL's AuditMiddleware consumes. When the audit middleware intercepts a request that reads or modifies a PII entity, it logs the access with the fields accessed, the user identity, and the timestamp. This audit trail is the evidence that the DPA asks for.
DataGovernance --> Compliance
The GDPR data map feeds directly into the Compliance DSL's compliance matrix. When [ComplianceFramework(ComplianceStandard.GDPR)] is declared, the Compliance DSL expects a complete GDPR data map. The cross-DSL analyzer verifies that every entity processed by the system has either a [GdprDataMap] or an explicit [DataClassification(ClassificationLevel.Public)] exclusion.
The generated compliance-matrix.md includes a section titled "GDPR Article 30 -- Record of Processing Activities" that is populated from the GdprDataMap.Records collection. No manual mapping required.
DataGovernance --> DDD
The [GdprDataMap] attribute references entity types using typeof(). The DDD DSL provides the [AggregateRoot] and [Entity] attributes. The cross-DSL analyzer verifies that every type referenced in a [GdprDataMap] is either an [AggregateRoot] or an [Entity] from the DDD DSL. If someone creates a [GdprDataMap(typeof(OrderDto), ...)] referencing a DTO instead of the domain entity, the analyzer catches it:
warning DGV-DDD001: [GdprDataMap] references type 'OrderDto' which is not
an [AggregateRoot] or [Entity]. GDPR data maps should reference domain
entities, not DTOs. Did you mean typeof(Order)?warning DGV-DDD001: [GdprDataMap] references type 'OrderDto' which is not
an [AggregateRoot] or [Entity]. GDPR data maps should reference domain
entities, not DTOs. Did you mean typeof(Order)?This ensures the GDPR data map describes the actual data model, not a projection of it.
The Payoff
Before this DSL:
- Backup schedules were in cron jobs that nobody checked.
- GDPR compliance was a spreadsheet maintained by someone who left the company.
- Recovery objectives were verbal commitments with no validation.
- Retention policies existed on paper but not in code.
- Seed data was tribal knowledge in SQL scripts.
After:
- Backup frequency, retention, and encryption are declared next to the service that owns the database. The generator produces the cron jobs, the Terraform resources, and the restore tests.
- The GDPR data map is a typed attribute on the governance class. The generator produces the Record of Processing Activities document. The analyzer catches missing data maps at compile time.
- RPO and RTO are typed values that the generator validates against actual backup frequency. If the backup runs daily but the RPO is 4 hours, the analyzer says so.
- Retention jobs are generated
BackgroundServiceimplementations. They run, they log, they enforce. - Seed data is declarative, environment-aware, ordered, and idempotent.
The auditor asks "where is your GDPR data map?" You run dotnet build and hand them the generated markdown.