Annotation-driven data retention for .NET and EF Core.
Cohort gives you a consistent way to say:
- this entity is retained
- this is how old it has to be before action is allowed
- this category should be purged, soft-deleted, or anonymised
From there it handles the awkward bits for you:
- finding eligible rows by age
- applying tenant predicates automatically
- respecting legal holds
- running purge, soft-delete, or anonymise mutations
- supporting immediate right-to-erasure with legal-minimum and active-hold blockers
- writing an audit trail of what happened
Postgres-only.
Use Cohort when you want retention to be part of your application model instead of a pile of ad hoc SQL jobs.
The core idea is simple:
- annotate EF entities with retention metadata
- map retention categories to rules
- run preview, sweep, or erasure through Cohort
Annotations declare membership. Category rules declare policy. Cohort executes that policy safely.
Two cases:
- purge short-lived operational data after 30 days
- keep a business record, but anonymise personal fields after 365 days
using Cohort.Application;
using Cohort.Domain;
using Cohort.Hosting;
using Cohort.Infrastructure.Migrations;
[Retain("session-notes", nameof(CreatedAt))]
[RetentionEntityId("a3f467fe-c5d0-4f17-9897-83c373cc1dc8")]
public sealed class SessionNote
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Body { get; set; } = "";
}
[Retain("case-contacts", nameof(CreatedAt))]
[RetentionEntityId("b7316df4-7db5-46ad-aea7-f65c4b430f73")]
public sealed class CaseContact
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? AnonymisedAt { get; set; }
[Anonymise(AnonymiseMethod.Null)]
public string? Email { get; set; }
[Anonymise(AnonymiseMethod.EmptyString)]
public string FullName { get; set; } = "";
}
public sealed class RetentionRules : IRetentionRuleProvider
{
public RetentionCategoryCapabilities? GetCapabilities(string category) => category switch
{
"session-notes" => new([Strategy.Purge]),
"case-contacts" => new([Strategy.Anonymise]),
_ => null,
};
public Task<RetentionRule?> ResolveAsync(
RetentionResolutionContext context,
CancellationToken ct)
{
RetentionRule? rule = context.Category switch
{
"session-notes" => new RetentionRule(TimeSpan.FromDays(30), Strategy.Purge),
"case-contacts" => new RetentionRule(TimeSpan.FromDays(365), Strategy.Anonymise),
_ => null,
};
return Task.FromResult(rule);
}
}Register Cohort and add its infrastructure tables to your EF model:
builder.Services.AddSingleton<IRetentionRuleProvider, RetentionRules>();
builder.Services.AddCohort<MyDbContext>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ConfigureCohortTables();
}
After adding ConfigureCohortTables(), generate a host-owned EF Core migration containing all
five Cohort tables and apply it before startup or any direct Cohort operation. Cohort validates
the installed schema but never creates or upgrades it at runtime.
What happens:
- old
SessionNoterows are deleted - old
CaseContactrows stay in place, but marked fields are scrubbed - tenant filtering is applied automatically
- held rows are skipped
- audit rows are written to Cohort tables
Once registered, Cohort can preview, sweep, and right-to-erasure retained entities using the rules you mapped. You can let the hosted worker run scheduled sweeps, or resolve the application services yourself when you want to trigger retention explicitly.
Every retained entity also requires a stable UUID identity:
[Retain("session-notes", nameof(CreatedAt))]
[RetentionEntityId("a3f467fe-c5d0-4f17-9897-83c373cc1dc8")]
public sealed class SessionNote { /* ... */ }
[Retain("category", nameof(Anchor))] says:
- this entity participates in retention
- it belongs to the given category
- age it using the given anchor column
[RetentionEntityId] is durable correlation metadata, not display metadata. Never change it
when renaming or moving the CLR type. Startup rejects missing, empty, malformed, or duplicate
identities. It identifies per-row audit detail and handler work independently of the CLR name.
Audit rows retain the human-readable CLR EntityType separately; within a run,
sweep_run_entity_summary rows are uniquely identified by the durable retention entity ID
together with category, tenant, and strategy.
Complete the configuration by mapping the category through IRetentionRuleProvider,
registering Cohort, and adding Cohort's infrastructure tables to the EF model as shown below.
Unannotated entities are implicitly exempt. Use [ExemptFromRetention("reason")] if you want that exemption to be explicit in code.
Guardrails enforced at startup validation:
PeriodandLegalMinmust be non-negative (RetentionRulerejects negative values — a negative period would compute a future cutoff and sweep everything).- Owned retained types, entities sharing a table with another EF type, and entities split across multiple tables are rejected. Cohort mutates one independently owned table per retained entity and will not guess which columns or rows belong to it.
- Retained entities must not participate in an EF inheritance hierarchy (TPH/TPT/TPC): sweep SQL targets the table without a discriminator.
- No
ON DELETE CASCADEforeign key may lead from a purgeable retained entity into another retained entity — a cascade would bypass the dependent's retention window, holds, and audit trail. - Convention marker attributes (
[RetentionRecordId],[RetentionTenant],[RetentionSoftDelete],[RetentionDeletedAt]) may each appear on at most one property. - Anchor,
DeletedAt, andAnonymisedAtcolumns must map totimestamp with time zone: Cohort compares and writes retention timestamps as UTC instants, and a naivetimestamp without time zonecolumn silently drifts with the session time zone.
Rows whose anchor column is NULL never match a cutoff comparison and are retained
indefinitely; prefer non-nullable anchors for purge categories.
Retained entities are tenant-scoped by default. They must expose a TenantId property, or mark an alternative property with [RetentionTenant], unless they are intentionally global and explicitly marked with [RetentionTenantless]. Declaring [RetentionTenantless] on an entity that also exposes a tenant property fails startup validation — the tenant property would win and the marker would be silently ignored.
Each category resolves directly through IRetentionRuleProvider to a RetentionRule:
PeriodStrategy- optional
LegalMin - optional per-rule audit detail
- optional provenance
The entity annotation does not decide whether a row is purged or anonymised. The resolved RetentionRule does.
GetCapabilities(category) synchronously declares every strategy that ResolveAsync can
return for that category. Capabilities are not a cache of the current rule: startup validates
the union of every declared strategy against each retained entity. A runtime rule whose
strategy was not declared is rejected. ResolveAsync receives category, tenant, logical time,
and alias path, so a host provider can apply tenant- and time-specific policy without hiding
its possible model requirements.
Register your IRetentionRuleProvider before AddCohort<TDbContext>(), declare every strategy each category can return, and call ConfigureCohortTables() in OnModelCreating. Startup validates the union of declared strategy requirements.
[ErasureSubject] metadata is also validated at startup: marked properties must be mapped
to physical columns and have compatible effective/provider types. Invalid erasure metadata
therefore prevents startup instead of failing the first subject request.
Map all Cohort-owned tables explicitly. The parameterless overload maps them to public;
the schema overload maps all five tables together:
modelBuilder.ConfigureCohortTables("retention");
All retained and Cohort-owned SQL identifiers are schema-qualified. Missing retained-table
schema resolves to the EF model default and then public; runtime behavior does not depend on
PostgreSQL search_path.
IRetentionPreviewgives you a count-only previewIRetentionSweepperforms the real sweepIRetentionErasureServiceruns subject erasure inside the same retention rulesIRetentionDeletionlets host deletion participate atomically in Cohort hold protection
IRetentionDeletion is scoped. Resolve it and your registered TDbContext from the same DI
scope, identify every row that the host callback will delete, and persist the callback's changes
through that context. Cohort canonicalises the record IDs, takes the same transaction-scoped
advisory locks used by hold creation and retention mutation, checks all targets for active holds,
then invokes the callback inside the same EF/Postgres transaction. It returns Protected without
invoking the callback when any target is held; callback failures propagate and roll back. The
operation owns the transaction, so call it when the scoped context has no current transaction.
The package test compiles this invocation example verbatim against the packed artifact:
public static class ReadmeRetentionOperations
{
public static async Task RunAsync(
IServiceProvider provider,
TenantContext tenant,
Guid subjectId,
CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
await provider.GetRequiredService<IRetentionPreview>().PreviewAsync(tenant, now, ct);
await provider.GetRequiredService<IRetentionSweep>().SweepAsync(tenant, now, ct);
await provider.GetRequiredService<IRetentionErasureService>().EraseAsync(
tenant,
new ErasureScope(subjectId),
now,
ct);
}
}| Strategy | What Cohort does | Typical use |
|---|---|---|
Purge |
Deletes rows past cutoff | short-lived operational data |
SoftDelete |
Sets the soft-delete flag | records you still want to hide rather than remove |
Anonymise |
Scrubs marked columns in place | data you still need structurally, but not personally |
Exempt |
Leaves rows alone | documented non-retained categories |
Anonymise categories require an idempotency marker: a nullable DateTimeOffset property
named AnonymisedAt by convention (or marked with [RetentionAnonymisedAt]). NULL
means not yet anonymised; the sweep filters on it and stamps it, so rows are scrubbed
exactly once instead of on every sweep. Startup validation enforces the marker.
For straightforward cases, mark columns with [Anonymise]:
[Anonymise(AnonymiseMethod.Null)]
public string? Email { get; set; }
[Anonymise(AnonymiseMethod.EmptyString)]
public string FullName { get; set; } = "";
[Anonymise(AnonymiseMethod.FixedLiteral, "[redacted]")]
public string Phone { get; set; } = "";
For custom logic, use AnonymiseWithAttribute:
[AnonymiseWith(typeof(MyCustomFactory))]
public string ExternalReference { get; set; } = "";
Mark one or more subject identifiers with [ErasureSubject]:
[Retain("user-data", nameof(CreatedAt))]
[RetentionEntityId("6b619c19-6e3c-44e8-a87f-975c68fd3988")]
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; }
[ErasureSubject]
public Guid? DelegateUserId { get; set; }
}You can mark multiple [ErasureSubject] properties on the same entity.
Any marked subject column equals the requested subject is treated as an erasure match.
Cohort erases rows that satisfy these conditions:
- any marked subject column equals the requested subject
- the row belongs to the requested tenant
- the resolved strategy is eligible for erasure
- no active hold covers the row
Ordinary Period never delays subject erasure. With no positive LegalMin, Cohort emits no
anchor predicate, so null and future anchors are eligible. A positive LegalMin adds the
strict predicate anchor < now - LegalMin; an exact-boundary or null anchor remains blocked,
and blocked null anchors are reported in NullAnchorCount. Active holds always block erasure.
Erasure refuses categories that resolve to the SoftDelete strategy: setting a flag leaves
personal data in the row, which rarely satisfies an erasure request. If it genuinely does,
opt in explicitly with new ErasureScope(subject, allowSoftDeleteAsErasure: true).
ErasureResult.DryRun tells you whether the call previewed (under Cohort:DryRun) or
actually mutated.
Erasure runs on the same execution model as sweeps (see Execution model):
the Started audit row commits before any mutation, rows are erased in independently
committed batches of SweepBatchSize, one entity's failure is recorded
(ErasureResult.EntityFailures, plus the run row's PartiallyFailed status and Error)
while erasure
continues for the subject's data in the remaining entities, and held counts are measured
directly — in dry runs too. One difference: erasure candidate selects wait on locked rows
(FOR UPDATE) instead of skipping them, because an erasure request must not silently miss
a row a concurrent transaction happens to hold.
Internally, Cohort builds a relational predicate for the marked subject columns.
By default Cohort assumes common EF names:
- record id:
Id - tenant id:
TenantId - soft-delete flag:
IsDeleted - deleted-at column:
DeletedAt - anonymised-at marker:
AnonymisedAt
You can override those globally:
{
"Cohort": {
"Conventions": {
"RecordIdPropertyName": "Id",
"TenantPropertyName": "OrganisationId",
"SoftDeletePropertyName": "IsDeleted",
"DeletedAtPropertyName": "DeletedAt",
"AnonymisedAtPropertyName": "AnonymisedAt"
}
}
}Or per entity with marker attributes:
[RetentionRecordId][RetentionTenant][RetentionSoftDelete][RetentionDeletedAt][RetentionAnonymisedAt]
Priority is:
- attribute
- global config
- built-in default
If you need side effects around mutated rows, register handlers with AddRowHandler<TEntity, THandler>().
Give long-lived handlers an explicit stable UUID through the identity argument. Without one,
queued work uses the handler's CLR type name; renaming that class dead-letters work still queued
under the old name.
Handlers run through the dispatcher surface (IRetentionRowDispatcher backed by RetentionRowDispatcher) and let you do things like:
- purge related files or blobs
- emit domain or integration events
- capture original values before mutation
The execution contract:
OnBeforeAsyncruns inside the open sweep transaction, before the row mutation. Treat it as capture-only: external side effects performed here survive a transaction rollback, and it can run for a row that is subsequently withheld (for example by a hold created mid-sweep).OnAfterAsyncis dispatched after commit with at-least-once delivery. Handlers must be idempotent;ctx.Attemptgreater than 1 means a possible retry of completed work.- Work stuck
InFlightafter a crash is reclaimed onceRowHandlerDispatch:ClaimTimeout(default 5 minutes) elapses; the reclaim counts as an attempt. AfterSweepSettledwork runs only once its run records completion or failure. A run leftStartedby a process crash is marked failed afterRowHandlerDispatch:SweepSettleTimeout, allowing that work to dispatch.- Captured row snapshots (
CapturedPayload) can contain pre-anonymisation personal data. They are cleared as soon as every handler for the row reaches a terminal state, with a backstop scrub afterRowHandlerDispatch:PayloadRetention(default 30 days). Queued handler work whose snapshot the backstop scrubbed can never complete, so it dead-letters immediately with the scrub named as the reason instead of burning its retry budget. Dead-letterLastErrorstores a sanitized diagnostic containing the exception type, safe machine code when available, and a diagnostic ID. It never stores arbitrary exception text or stack traces. - Persisted payloads name CLR types, so deserialisation is allow-listed: snapshot values round-trip only as well-known scalars, property types of the swept entity, or types declared in the entity's or its registered handlers' assemblies. A tampered payload naming anything else dead-letters instead of materialising an arbitrary type, and the persisted entity type resolves only against registered retained entities.
{
"Cohort": {
"Schedule": "0 2 * * *",
"DryRun": false,
"KillSwitch": false,
"AuditObservers": {
"Timeout": "00:00:05"
}
}
}| Key | Default | Description |
|---|---|---|
Schedule |
null |
Cron expression, evaluated in UTC. null means the worker is disabled. |
DryRun |
false |
Run scheduled sweeps as count-only audited runs instead of mutating data. The worker calls the sweep engine's dry-run path, which writes the same run and entity audit trail with sweep_run.DryRun set. Direct IRetentionPreview calls remain unaudited previews, IRetentionSweep.SweepAsync refuses to run, and EraseAsync returns counts without mutating (ErasureResult.DryRun is true). |
KillSwitch |
false |
Finish the current iteration, then skip future ticks. |
SweepBatchSize |
5000 |
Maximum rows selected, locked, and mutated per transaction. Each batch commits independently. |
AuditObservers:Timeout |
00:00:05 |
Maximum time Cohort waits for each observer to handle one committed event. Each observer has an independent timeout. |
RowHandlerDispatch:PollInterval |
00:00:10 |
Delay between dispatcher polling passes. |
RowHandlerDispatch:BatchSize |
50 |
Maximum queued handler statuses claimed in one dispatcher batch. Valid range: 1 to 10000. |
RowHandlerDispatch:MaxParallelism |
4 |
Maximum rows dispatched concurrently. Handlers for one row remain ordered. Valid range: 1 to 256. |
RowHandlerDispatch:MaxAttempts |
10 |
Maximum delivery attempts before dead-lettering. Valid range: 1 to 1000. |
RowHandlerDispatch:BaseBackoff |
00:00:01 |
Base delay for exponential retry backoff. |
RowHandlerDispatch:ClaimTimeout |
00:05:00 |
Visibility timeout before abandoned in-flight work is reclaimed. |
RowHandlerDispatch:SweepSettleTimeout |
01:00:00 |
Age after which an unowned Started run is recovered as failed. |
RowHandlerDispatch:PayloadRetention |
30.00:00:00 |
Backstop retention for potentially sensitive captured row snapshots. |
Worker semantics worth knowing:
- Hosts must apply EF Core migrations before starting Cohort. Startup validates the installed Cohort schema but does not mutate it.
- A failed iteration (database outage, runtime misconfiguration) is logged and the worker retries at the next scheduled occurrence. It does not stop the host.
- The worker sweeps every tenant returned by
IRetentionTenantSource. The default source adapts a singletonTenantContextregistration; multi-tenant hosts register their own source enumerating all tenants. - Tenant passes execute sequentially in first-seen order. Duplicate tenant IDs execute once; if duplicate entries disagree on jurisdiction or tags, Cohort logs a warning and uses the first context.
- Replicas coordinate through a Postgres advisory lock: only one instance sweeps a given occurrence; the others skip and log.
- Missed occurrences are skipped, not caught up: the next occurrence is always computed from the current time.
- Sweeps triggered by the worker are audited as
Scheduled; direct calls toIRetentionSweep.SweepAsyncare audited asManual.
This release intentionally changes the pre-1.0 API and database contract. Relational strategy,
reflection, ordering, and authoritative audit-writer implementation types are internal;
consumers configure and invoke retention through annotations, hosting extensions, and the
application ports documented here. Replace category repository/resolver registrations with one
IRetentionRuleProvider, and replace custom audit writers with best-effort
IRetentionAuditObserver registrations or database/CDC export.
RetentionRule is now an invariant-preserving getter-only record. Construct a replacement rule
through its public constructor when policy changes; object initializers and with mutation are no
longer supported. This prevents copied rules from bypassing period, strategy, and audit-detail
validation.
Row-level public terminology now consistently uses RecordId. Rename consumers of
SweepEvent.RowDetail.EntityId and RetentionAfterContext<TEntity>.EntityId to RecordId at the
same time as applying the database rename below. RowHandlerPriorityAttribute.GetPriority and
DefaultPriority were removed; set [RowHandlerPriority(value)] on handlers and treat an absent
attribute as unspecified rather than calling library helper methods.
The sample AddStableRetentionEntityIdentity migration temporarily adds nullable UUID
RetentionEntityId columns, explicitly backfills known sample CLR names, validates that all
historical rows were mapped, then makes the columns required. New audit and handler rows
always persist the UUID as durable correlation metadata; per-row detail and handler dispatch
use it as retained-entity identity. Hosts with renamed historical CLR types must add explicit
mappings before applying their equivalent migration. EntityType remains readable
diagnostic metadata but is not part of summary uniqueness.
Legal holds use the durable retention entity ID rather than a physical table name, so table renames do not detach a hold from its retained entity.
RetentionEntityId and RecordId are deliberately different identities:
RetentionEntityIdis the stable UUID assigned to a retained entity type. It survives CLR and table renames and correlates rules, holds, summaries, row details, and handler work.RecordIdis the canonical PostgreSQL text identity of one row. Cohort derives it using the mapped provider/store type, so UUID, integer, string, and converted keys remain scoped consistently.
The sample RenameSweepRowDetailEntityIdToRecordId migration renames the row-identity
column in place, preserving audit history and dependent indexes. Hosts must add the
equivalent forward migration to rename sweep_run_row_detail."EntityId" to "RecordId";
schema-qualify that operation, and do not drop and recreate the column or rewrite an
already-applied migration. The sample's following ExplicitCohortSchemas migration changes only
EF model metadata and intentionally emits no SQL because the existing Cohort tables are already
in public.
When adopting this release, host-owned forward migrations must:
- Rename
sweep_run_row_detail."EntityId"to"RecordId"in place. - Call
ConfigureCohortTables()orConfigureCohortTables(schema)in the finalized model. - Move all five Cohort tables together when changing schema, preferably with PostgreSQL
ALTER TABLE ... SET SCHEMAso table identity and dependent objects are preserved. - Preserve the summary and row-detail foreign keys to
sweep_run, and the handler-status foreign key to row detail. - Preserve existing hold and audit rows; do not rewrite historical migrations.
Deploy only after correcting any newly surfaced provider-capability, relational-shape, strategy-specific, or erasure-subject startup failures.
Sweeps and erasure are batched and incremental:
- Candidate rows are selected with
FOR UPDATE SKIP LOCKEDin batches ofSweepBatchSize; each batch mutates and commits independently, so a large backlog never sits in one unbounded transaction and a failure loses only the current batch. - The
Startedaudit row commits immediately, before any mutation, withStatus = StartedandSettledAt = NULL. A terminal event setsStatustoSucceeded,PartiallyFailed,Failed, orCancelledand recordsSettledAt. The dispatcher marks staleStartedrows as failed afterRowHandlerDispatch:SweepSettleTimeoutwhen a process dies mid-run. - One entity's failure is recorded (run row
Status = PartiallyFailedandError, plusRetentionSweepResult.EntityFailures/ErasureResult.EntityFailures) and the run continues with the remaining entities. - Rows skipped by a failing
OnBeforeAsynchandler are excluded from the remaining batches of the run (they are dead-lettered once and stay behind for the next run), so a failing row neither spins the batch loop nor blocks the eligible rows behind it. A batch that makes no progress at all stops the loop for that entity. HeldCountin summaries is measured directly (rows past cutoff with an active hold), not inferred from candidate arithmetic.IRetentionSweep,IRetentionPreview, andIRetentionErasureServiceare singleton, scope-owning ports. Every call runs in a fresh DI scope so Cohort's raw SQL cannot share the caller's trackedDbContextor ambient transaction. Pass operation context through the request contracts; caller-scoped services and transactions do not flow into the operation. Singleton decorators may wrap these ports, but scoped decorators must not depend on their own scope participating in Cohort's execution.
public static class ReadmeLegalHolds
{
public static Task CreateAsync(
IRetentionHoldsRepository holds,
Guid noteId,
Guid tenantId,
DateTimeOffset now,
CancellationToken ct) =>
holds.CreateAsync(
new RetentionHoldRequest(
holdId: Guid.NewGuid(),
retentionEntityId: Guid.Parse("a3f467fe-c5d0-4f17-9897-83c373cc1dc8"),
recordId: noteId.ToString(),
tenantId: tenantId,
reason: "Litigation hold - case #12345",
createdAt: now,
expiresAt: now.AddYears(1)),
ct);
}Held records survive all strategies. Holds are checked in SQL via a NOT EXISTS subquery, not via an in-memory row pass. Hold activity is evaluated against the database wall clock, not the sweep's logical now: a hold created yesterday protects its row even from a backdated sweep.
CreateAsync validates its input so a hold cannot silently protect nothing:
RetentionEntityIdmust identify a retained entity in the current EF model, or creation throws.RecordIdis parsed and canonicalized using the mapped key's PostgreSQL store type. For a Guid key, malformed Guid values are rejected; integer, string, and provider-converted keys retain their own canonical PostgreSQL representation.- For retained tenant-scoped tables, if the target row already exists its tenant must
match
TenantId— sweeps only honour holds whose tenant matches the row's, so a mis-scoped hold would persist while protecting nothing. - The canonical
RecordIdmust identify an existing row. Cohort acquires the same entity/tenant/record advisory lock used by mutation before checking existence and inserting the hold, so a concurrent Cohort sweep cannot pass the hold between validation and creation. - Tenantless entities require a null
TenantId; typed and provider-converted record IDs are canonicalized before existence and hold matching.
Every sweep unconditionally writes its authoritative ledger to Cohort-managed tables:
sweep_runsweep_run_entity_summarysweep_run_row_detail
Summary rows carry:
- category
- strategy
- affected count
- held count
- skipped count
- resolved period
- optional provenance via
RuleSourceandRuleReason
Per-row detail is opt-in through AuditRowDetail.PerRow.
The EF audit writer is internal and cannot be replaced through dependency injection. Mutation,
row detail, and entity progress commit in the same transaction. Consumers can register any
number of IRetentionAuditObserver implementations for post-commit export:
services.AddSingleton<IRetentionAuditObserver, AuditExporter>();
services.AddCohort<MyDbContext>();
Observers receive Started, EntityProgress, RowDetail, EntitySummary, and the terminal
Completed, PartiallyFailed, Failed, or Cancelled event in committed lifecycle order.
Batch RowDetail and EntityProgress events are not delivered until their mutation transaction
has committed, and rolled-back events are never delivered. Each observer is isolated and bounded
by AuditObservers:Timeout; exceptions and timeouts are logged but never alter committed data,
the retention result, or run status.
RowDetail events contain the canonical RecordId and TenantId. Treat observer implementations
and their downstream transport as sensitive-data processors: protect access, logs, queues, and
exports accordingly.
Observer delivery is best effort and has no durable outbox. A process crash can lose a notification after the database commit. Guaranteed integrations must poll the authoritative tables, use CDC, or implement a durable host-owned outbox.
Failures derived from external exceptions persist and return a sanitized envelope containing the
exception type, safe machine code when available (for example PostgreSQL SQLSTATE), and a random
diagnostic ID. Cohort-defined machine-safe reasons may be stored as plain Error or LastError
values instead. Only diagnostic Error and LastError text is privacy-sanitized: exception
messages, stack traces, SQL values, and subject identifiers are excluded there, but the deliberately
identifying RecordId and TenantId fields remain in row-detail events. Structured logs retain the
original exception with the same diagnostic ID for protected operational diagnosis. Existing
historical Error and LastError values are not rewritten during upgrade.