Cohort 0.1.0
See the version list below for details.
dotnet add package Cohort --version 0.1.0
NuGet\Install-Package Cohort -Version 0.1.0
<PackageReference Include="Cohort" Version="0.1.0" />
<PackageVersion Include="Cohort" Version="0.1.0" />
<PackageReference Include="Cohort" />
paket add Cohort --version 0.1.0
#r "nuget: Cohort, 0.1.0"
#:package Cohort@0.1.0
#addin nuget:?package=Cohort&version=0.1.0
#tool nuget:?package=Cohort&version=0.1.0
Cohort
Annotation-driven GDPR retention for .NET / EF Core. Declare retention rules on your entities, and Cohort sweeps rows past their retention period — purge, soft-delete, or anonymise. Postgres-only.
Quick start
1. Tag entities and define what happens to them
// The entity — [Retain] declares the category and the age anchor
[Retain("short-lived", nameof(CreatedAt))]
public sealed class Note
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Body { get; set; } = "";
}
// The rules — map each category to a strategy and retention period
public sealed class MyCategoryRepository : IRetentionCategoryRepository
{
public Task<IRetentionRuleResolver?> GetAsync(string category, CancellationToken ct)
{
IRetentionRuleResolver? resolver = category switch
{
"short-lived" => new StaticRetentionRuleResolver(
new RetentionRule(TimeSpan.FromDays(30), Strategy.Purge)),
"pii" => new StaticRetentionRuleResolver(
new RetentionRule(TimeSpan.FromDays(90), Strategy.Anonymise)),
_ => null,
};
return Task.FromResult(resolver);
}
}
[Retain("short-lived", nameof(CreatedAt))] says "this entity belongs to the short-lived category, age it by CreatedAt." The category repository says "short-lived means purge after 30 days." Neither piece does anything without the other.
Unannotated entities are implicitly exempt — no annotation needed to opt out. Use [ExemptFromRetention("reason")] if you want to document the exemption explicitly.
2. Wire it up
// Register your category repository BEFORE AddCohort
builder.Services.AddSingleton<IRetentionCategoryRepository, MyCategoryRepository>();
builder.Services.AddSingleton(new TenantContext(tenantId, "uk", new Dictionary<string, string>()));
builder.Services.AddCohort<MyDbContext>();
Call ConfigureCohortTables() in your OnModelCreating to add Cohort's infrastructure tables (retention_holds, sweep_run, etc.) to your migration history:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// your entity config...
modelBuilder.ConfigureCohortTables();
}
Strategies
| Strategy | Behaviour | Entity requirements |
|---|---|---|
Purge |
DELETE rows past cutoff |
[Retain], anchor property, record ID |
SoftDelete |
SET IsDeleted = true |
Above + bool IsDeleted property |
Anonymise |
Scrub [Anonymise]-marked fields |
Above + at least one [Anonymise] field |
Exempt |
Skip entirely | [ExemptFromRetention] or no annotation |
TenantId is optional. Single-tenant apps work without it — the sweep SQL simply omits the tenant filter. Multi-tenant apps get AND "TenantId" = @tenantId in every query automatically when the property exists.
Anonymise methods
[Retain("pii", nameof(CreatedAt))]
public sealed class Contact
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
[Anonymise(AnonymiseMethod.Null)]
public string? Email { get; set; }
[Anonymise(AnonymiseMethod.EmptyString)]
public string Name { get; set; } = "";
[Anonymise(AnonymiseMethod.FixedLiteral, "[redacted]")]
public string Phone { get; set; } = "";
}
Convention overrides
Property names are resolved by convention (Id, TenantId, IsDeleted, DeletedAt). Override globally via config, or per-entity with marker attributes.
Global — applies to all entities:
{
"Cohort": {
"Conventions": {
"RecordIdPropertyName": "Id",
"TenantPropertyName": "OrganisationId",
"SoftDeletePropertyName": "IsDeleted",
"DeletedAtPropertyName": "DeletedAt"
}
}
}
Per-entity — attribute wins over global config:
[Retain("orders", nameof(PlacedAt))]
public sealed class Order
{
[RetentionRecordId]
public Guid OrderId { get; set; }
[RetentionTenant]
public Guid OrganisationId { get; set; }
public DateTimeOffset PlacedAt { get; set; }
}
Available markers: [RetentionRecordId], [RetentionTenant], [RetentionSoftDelete], [RetentionDeletedAt].
Priority: attribute > global config > built-in default.
Right-to-erasure (Art. 17)
Mark the subject identifier with [ErasureSubject]:
[Retain("user-data", nameof(CreatedAt))]
public sealed class UserRecord
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
[ErasureSubject]
public Guid UserId { get; set; }
}
Then trigger erasure:
var result = await erasureService.EraseAsync(tenant, new ErasureScope(userId), DateTimeOffset.UtcNow);
Cohort walks every entity with a matching [ErasureSubject] and applies the category's strategy. Held records are respected. The full sweep is audited.
Configuration
{
"Cohort": {
"Schedule": "0 2 * * *",
"DryRun": false,
"KillSwitch": false,
"ApplyMigrations": false
}
}
| Key | Default | Description |
|---|---|---|
Schedule |
null |
Cron expression (5 or 6 fields). null = worker disabled. |
DryRun |
false |
Run sweeps as SELECT COUNT(*) instead of DELETE/UPDATE. Audit events still fire. |
KillSwitch |
false |
Finish current iteration, skip all subsequent ticks. Hot-reloadable. |
ApplyMigrations |
false |
Run MigrateAsync() on startup. Cannot combine with DryRun or KillSwitch. |
Legal holds
await holdsRepo.CreateAsync(new RetentionHoldRequest(
HoldId: Guid.NewGuid(),
TableName: "notes",
RecordId: noteId.ToString(),
TenantId: tenantId,
Reason: "Litigation hold — case #12345",
CreatedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.AddYears(1)
));
Held records survive all strategies. Holds are checked at SQL level via a NOT EXISTS subquery — no per-row C# check.
Audit trail
Every sweep writes to three tables (created by ConfigureCohortTables()):
sweep_run— one row per sweep (timestamps, trigger, dry-run flag, total affected)sweep_run_entity_summary— per-entity counts (category, strategy, affected, held)sweep_run_row_detail— per-row detail, opt-in per category (AuditRowDetail.PerRowon the rule) or per entity ([Retain("cat", nameof(Anchor), AuditRowDetail = AuditRowDetail.PerRow)]). Entity-level wins over category-level.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net9.0
- Cronos (>= 0.11.1)
- Microsoft.EntityFrameworkCore (>= 9.0.0)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.0)
- Microsoft.Extensions.Hosting (>= 9.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.9.0 | 90 | 10/9/2026 |
| 0.8.0 | 211 | 10/5/2026 |
| 0.7.3 | 177 | 9/30/2026 |
| 0.7.2 | 94 | 9/29/2026 |
| 0.7.1 | 87 | 9/29/2026 |
| 0.7.0 | 87 | 9/29/2026 |
| 0.6.3 | 256 | 8/11/2026 |
| 0.6.2 | 329 | 7/13/2026 |
| 0.6.1 | 128 | 7/12/2026 |
| 0.5.0 | 129 | 6/12/2026 |
| 0.4.1 | 127 | 6/12/2026 |
| 0.4.0 | 125 | 6/12/2026 |
| 0.3.1 | 174 | 4/14/2026 |
| 0.3.0 | 118 | 4/14/2026 |
| 0.2.0 | 135 | 4/14/2026 |
| 0.1.2 | 121 | 4/14/2026 |
| 0.1.1 | 126 | 4/12/2026 |
| 0.1.0 | 126 | 4/12/2026 |