Marten
Tayra integrates with Marten by wrapping the configured serializer with TayraSerializer. This provides transparent encryption and decryption of [PersonalData] fields in both documents and events stored in PostgreSQL's JSONB columns.
Prerequisites
Tayra ships a package per Marten major (pick by your Marten version), both requiring PostgreSQL 13+:
Tayra.Marten9- Marten 9.x, .NET 9+.Tayra.Marten8- Marten 8.x, .NET 8+.
The framework major is in the package name; the version number is Tayra's own. See Installation.
Install
Install the package that matches your Marten major:
dotnet add package Tayra.Marten9dotnet add package Tayra.Marten8Either package exposes the same Tayra.Marten namespace and API, so the code below is identical regardless of which you install.
Minor versions within a Marten major
Install whichever Marten 9.x (or 8.x) you want; Tayra tracks the whole line, not the version it happens to be built against. TayraSerializer resolves Marten's serializer members at runtime rather than baking them in at compile time, so it keeps working when Marten reshuffles its serializer contract between minor releases - as Marten 9.20 did, moving most of ISerializer onto Weasel.Storage.IStorageSerializer.
Upgrading from 2.5.0 or earlier on Marten 9.20+
Tayra.Marten9 2.5.0 and earlier throw MissingMethodException on every document read against Marten 9.20 or newer, because those builds call the serializer members where Marten used to declare them. Writes appear to succeed, so the failure surfaces on the first query rather than at startup. Upgrade Tayra.Marten9; no data migration or configuration change is involved, and nothing already stored is affected.
If a future Marten changes the contract in a way this build does not recognise, Tayra fails at startup with a message naming the member it could not bind, rather than part-way through a query.
Setup
Call UseTayra() on Marten's StoreOptions to enable PII encryption. This wraps Marten's default serializer with TayraSerializer:
// Configure Marten with Tayra encryption.
// UseTayra() wraps the serializer so PII fields are encrypted in JSONB storage.
var connectionString = "Host=localhost;Port=5432;Database=tayra_sample;Username=postgres;Password=postgres";
services.AddMarten(opts =>
{
opts.Connection(connectionString);
// Enable Tayra encryption for documents and events
opts.UseTayra(tayra);
});How TayraSerializer Works
TayraSerializer is a decorator around Marten's existing ISerializer. When serializing (e.g., ToJson()), it encrypts [PersonalData] fields before passing the object to the inner serializer. When deserializing (e.g., FromJson<T>()), it decrypts the fields after the inner serializer reconstructs the object. All non-PII fields pass through untouched.
Custom serializers
Because TayraSerializer is a decorator, a custom serializer is fully supported - Tayra wraps yours and delegates all serialization (naming policy, converters, enum storage, casing) to it, adding only the encrypt/decrypt of [PersonalData]. This works for opts.Serializer(new MyCustom()), opts.UseSystemTextJsonForSerialization(...), opts.UseNewtonsoftForSerialization(...), or any third-party ISerializer.
The only rule is that Tayra must wrap last:
- DI registration (
services.UseTayra()) - recommended. Tayra runs after your entireAddMarten(opts => …)lambda, so it wraps whatever serializer you configured, wherever you set it. opts.UseTayra(tayra)inside the lambda. This wraps the serializer set at that moment. Callingopts.Serializer(...)(orUseSystemTextJsonForSerialization, etc.) afteropts.UseTayra(tayra)overwrites the wrapper - callUseTayralast.
Encryption-off is a startup failure, not a silent one
If a custom serializer (or another library's IConfigureMarten registered after Tayra) replaces the wrapper so the store's final serializer is not a TayraSerializer, the DI registration's startup guard throws at host startup rather than letting PII write in cleartext. If you hit this, ensure UseTayra() is applied last.
Ancillary stores (AddMartenStore<T>)
If you run additional stores via Marten's ancillary-store feature (services.AddMartenStore<T>(...), common in a modular monolith), UseTayra() covers them automatically - a single call protects the default AddMarten() store and every ancillary store. No per-store opt-in is required:
services.AddTayra(opts => opts.LicenseKey = licenseKey)
.UsePostgreSqlKeyStore(/* ... */);
// Default store
services.AddMarten(opts => { /* ... */ });
// Ancillary journaling store whose documents/events carry PII
services.AddMartenStore<IJournalStore>(opts => { /* ... */ });
services.UseTayra(); // decorates BOTH storesTayra registers its serializer wrapper as a global Marten configuration, which Marten applies to every store - and, for ancillary stores, runs it last, so Tayra always wraps the final serializer. If an ancillary store carries no [PersonalData] types, the wrapper is a transparent pass-through, so covering every store has no downside.
How It Works
Tayra wraps Marten's ISerializer to transparently encrypt and decrypt PII fields during serialization. Encryption is selective by type - only entities with [PersonalData] annotations are encrypted. Types without PII attributes pass through unmodified.
Documents
Annotate your Marten document classes with [DataSubjectId] and [PersonalData]:
public class CustomerDocument
{
public Guid Id { get; set; }
[DataSubjectId]
public string SubjectId { get; set; } = "";
[PersonalData]
public string Name { get; set; } = "";
[PersonalData(ReplacementValue = "redacted@example.com")]
public string Email { get; set; } = "";
/// <summary>
/// Not annotated - stored as plaintext in JSONB.
/// </summary>
public string AccountType { get; set; } = "";
}[DataSubjectId]onSubjectIdidentifies the data owner.[PersonalData]onNameandEmailmarks them for encryption.AccountTypeis not annotated and stored as plaintext in JSONB.
When you store this document with session.Store(customer), the Name and Email values in the JSONB column will contain AES-256-GCM ciphertext. When you load it with session.Load<CustomerDocument>(id), the fields are decrypted back to plaintext automatically.
Events
Event payloads are encrypted the same way as documents:
public class CustomerRegisteredEvent
{
[DataSubjectId]
public string SubjectId { get; set; } = "";
[PersonalData]
public string CustomerName { get; set; } = "";
[PersonalData(ReplacementValue = "redacted@example.com")]
public string CustomerEmail { get; set; } = "";
public DateTime RegisteredAt { get; set; }
}When you append this event to a stream, the CustomerName and CustomerEmail values are encrypted in the event store. Events are immutable in Marten, so the encrypted values are permanent - which is exactly what makes crypto-shredding work.
Event Sourcing and GDPR
Crypto-shredding is the recommended approach for GDPR compliance in event-sourced systems. Instead of rewriting event history (which violates event sourcing principles), you delete the encryption key. The events remain intact, but the PII fields become permanently unreadable.
Crypto-Shredding
Delete a data subject's encryption keys to make all their PII permanently unreadable:
// GDPR "right to be forgotten" - destroy encryption keys for a data subject.
// After this, any attempt to decrypt the subject's data returns replacement values.
//
// Usage with Marten IDocumentSession:
// await session.ShredDataSubjectAsync(cryptoEngine, subjectId);
// await session.SaveChangesAsync();
// Example (standalone, without a live Marten session):
var subjectId = "cust-42";
await cryptoEngine.DeleteAllKeysAsync(subjectId);
Console.WriteLine($"Crypto-shredded all keys for subject '{subjectId}'.");The ShredDataSubjectAsync extension method on IDocumentSession calls cryptoEngine.DeleteAllKeysAsync() to destroy all encryption keys for the specified subject.
Check if Shredded
Verify whether a data subject has been crypto-shredded:
// Check if a data subject's keys have been shredded.
//
// Usage with Marten IDocumentSession:
// bool shredded = await session.IsDataSubjectShreddedAsync(cryptoEngine, subjectId);
//
// Standalone equivalent:
var keyExists = await cryptoEngine.KeyExistsAsync(subjectId);
var isShredded = !keyExists;
Console.WriteLine($"Subject '{subjectId}' is shredded: {isShredded}");The IsDataSubjectShreddedAsync extension checks whether the subject's encryption key still exists in the key store. If the key is gone, the subject has been shredded.
Projections
Projections work with no extra configuration. Once UseTayra() is registered, every event is deserialized through TayraSerializer, so [PersonalData] fields are already decrypted by the time they reach your projection. Register projections exactly as you would in plain Marten:
// Projections need no Tayra-specific wiring. Because UseTayra() wraps the
// serializer, every event is decrypted on read before it reaches your
// projection — inline, live aggregation, and the async daemon alike.
// Register projections exactly as in plain Marten:
services.AddMarten(opts =>
{
opts.Connection(connectionString);
opts.UseTayra(tayra);
// Standard Marten registration — events arrive decrypted in Apply/ApplyAsync,
// and any [PersonalData] on the projected document is re-encrypted at rest.
// opts.Projections.Add(myProjection, ProjectionLifecycle.Inline);
});
Console.WriteLine("\nProjections require no special API: the serializer decrypts events on every read path.");
Console.WriteLine("[PersonalData] on projected read models is re-encrypted at rest automatically.");Why No Special API Is Needed
TayraSerializer is the single seam through which Marten deserializes event data (ISerializer.FromJson). Marten uses that same seam for inline projections, live aggregations (AggregateStreamAsync, FetchForWriting), and the async projection daemon alike. Your Apply/ApplyAsync handlers therefore receive cleartext values and never need to know about encryption.
If a projection writes a document with its own [PersonalData] fields (a read model), those fields are re-encrypted at rest automatically, because the projected document is written back through TayraSerializer as JSONB.
Bulk key prefetch for large rebuilds
A full projection rebuild decrypts every event as it is replayed. Each distinct data subject needs its DEK, and the first time a subject is seen its key is a cache miss that hits the key store. On a rebuild spanning millions of subjects, that is millions of single-row key lookups - the dominant cost of the rebuild.
ICryptoEngine.PrefetchKeysAsync warms the in-memory key cache in one batched round trip. IKeyStore.GetManyAsync collapses many single-row reads into a single query (WHERE key_id = ANY(...) on PostgreSQL/Aurora, a chunked IN (...) on SQLite, a dictionary scan in-memory); the envelope and tenant-aware decorators pass the batch straight through. Stores with no native multi-get (Vault, cloud secret stores) fall back to a loop, so the API is always available.
The manual pattern: enumerate the distinct subject key ids you are about to process, prefetch them, then run the work.
var engine = provider.GetRequiredService<ICryptoEngine>();
// Warm the cache in chunks so the whole set never sits in memory at once
// and no single query is unbounded.
foreach (var chunk in distinctSubjectKeyIds.Chunk(10_000))
{
await engine.PrefetchKeysAsync(chunk, ct);
// ... rebuild the events for those subjects; decrypts now hit the cache ...
}Prefetch is best-effort: a key-store failure while warming is swallowed and never breaks the rebuild - the authoritative per-key decrypt path stays fail-closed and surfaces any real error with proper attribution.
Turnkey: warm a projection's keys in one call
You do not have to enumerate the subject ids yourself. Tayra ships two IServiceProvider extensions (in Tayra.Marten8 / Tayra.Marten9) that discover a projection's event types, scan mt_events for the distinct [DataSubjectId] values those events carry, and warm them in chunks:
// Warm the cache, then rebuild - one call.
await app.Services.RebuildProjectionWithPrefetchAsync<PatientProjection>();
// Or warm only (drive the rebuild yourself):
await app.Services.PrefetchKeysForProjectionAsync<PatientProjection>();The receiver is the application IServiceProvider, not the Marten IDocumentStore: the store does not carry Tayra services, so only the app container can reach both the store and the crypto engine. Both methods take an optional chunkSize (default 5000); RebuildProjectionWithPrefetchAsync also takes an optional per-shard timeout (default 30 minutes).
Under the hood the scan reads each handled event type's stored alias (mt_events.type) and the JSON field name for its [DataSubjectId] member (derived from the serializer casing), streams SELECT DISTINCT data ->> '<field>' ..., and warms each chunk through the Core helper below.
If you already hold the domain ids (for example from a bounded query of your own), the Core IFieldEncrypter / ITayra surface takes them directly and builds the key ids from the entity's [DataSubjectId] metadata (its Prefix and Group), so you never hand-assemble key id strings:
var tayra = app.Services.GetRequiredService<ITayra>();
foreach (var chunk in patientIds.Chunk(5000))
{
await tayra.PrefetchForSubjectsAsync<PatientAdmitted>(chunk);
// ... process those subjects; decrypts now hit the cache ...
}Be honest about what this is and is not:
- Turnkey, not transparent. It is one call for a full rebuild whose subject set is knowable up front - not per-page batching inside the daemon. Fully automatic, per-page prefetching still depends on the upstream Marten pre-deserialize hook tracked in
planning/marten-eventloader-prefetch-hook-proposal.md. - Best for full rebuilds. It scans the whole event table for the projection's types; for a small incremental catch-up the scan can cost more than it saves.
- DataEmbedded / single-tenant only. The scan runs across all tenants with subject-derived key ids, which line up with the daemon's lookups exactly when subject ids carry their own org prefix (DataEmbedded) or there is a single tenant. Under an ambient
TenantAwareKeyStorethe daemon has no ambient tenant, so neither the daemon's decryption nor these key ids line up - use DataEmbedded for daemon rebuilds. - Base-version warming only. Same rotation caveat as the manual pattern: rotated subjects (
subject:vN) still take a per-key miss.
Caveats, stated plainly:
- Not automatic inside the async daemon (yet). Marten deserializes (and therefore decrypts) events inline while loading each event page, with no supported pre-deserialize seam, so Tayra cannot batch the keys for a page before decryption runs. Fully automatic per-page prefetching depends on an upstream Marten change and is tracked separately. Today you drive the prefetch yourself around your rebuild loop, or ahead of a batch of streams you are about to load.
- Chunk it; do not prefetch millions at once. A single huge upfront prefetch risks cache-TTL expiry mid-rebuild (warmed keys evicted before you reach them) and memory pressure. Prefetch in chunks sized to your
KeyCacheDurationand working set. - Prefetch warms the base (version 0) key. A rotated subject encrypts under a versioned key (
subject:v3), and the version is embedded per document, so a rotated subject still takes a per-key miss on first access. Prefetch helps the common un-rotated case, which is the bulk of most datasets.
Rolling-window pacer (very large rebuilds)
The turnkey call above warms every discoverable subject up front and then rebuilds. That is ideal until the subject set is so large - tens of millions of subjects - that holding all warmed keys in memory at once is too much. For that case there is an opt-in overload that takes a PrefetchPacing instead of a chunk size:
await app.Services.RebuildProjectionWithPrefetchAsync<PatientProjection>(new PrefetchPacing
{
WindowEvents = 100_000, // events per scan/warm window
WarmAheadWindows = 2, // keep this many windows warmed ahead of the daemon
EvictBehindWindows = 1, // evict windows this far behind the daemon
MaxWarmedSubjects = 500_000, // hard cap on subjects kept warmed at once (null = no cap)
});What it does: it runs the rebuild and, alongside it, a pacer that warms a bounded window of subject keys just ahead of the daemon's observed position and evicts windows the daemon has already passed. The result is a rolling working set - warm-ahead plus evict-behind - so the number of keys held at once stays bounded no matter how large the rebuild is. Eviction here is cache-only (ICryptoEngine.EvictCachedKeys / IFieldEncrypter.EvictCachedKeysForSubjects<TEntity>); it never deletes a key from the store, so a subject whose key is evicted is simply re-loaded if it is needed again. It is not crypto-shredding.
How it tracks the daemon: the pacer subscribes to the async daemon's progress tracker and follows the latest processed event sequence for the projection's shard, pacing its windowed SELECT DISTINCT ... WHERE seq_id > @from AND seq_id <= @to subject scans against that position. seq_id is a globally ordered bigint, so one position drives the whole scan.
When to use it: reach for the pacer only when the simple warm-all overload would hold too many keys at once (roughly 10M+ subjects). Below that, the simpler overload is less machinery for the same result.
Best-effort by design: the rebuild is authoritative. The pacer is a pure optimization, so any pacer failure is caught and logged and never breaks the rebuild - the rebuild still completes, just unpaced (correct, only not accelerated). A watchdog logs one warning if the daemon position stops advancing and pacing cannot make progress.
Scope (v1): single-shard only - conjoined multi-tenancy or single-tenant, one "{Name}:All" shard over one global seq_id. If the projection resolves to multiple shards (per-tenant-partitioned or database-per-tenant tenancy), the single-position pacer cannot track them correctly, so it logs a warning and falls back to the simple warm-all-then-rebuild path.
Binary events Tayra.Marten9
Marten 9 can serialize events to mt_events.bdata via a separate IEventBinarySerializer ([BinaryEvent], opts.Events.UseBinarySerializer<T>(), or a store-wide opts.Events.DefaultBinarySerializer). That path bypasses the document serializer, so Tayra protects it with a binary-format counterpart, TayraBinaryEventSerializer:
- Store-wide default - set
opts.Events.DefaultBinarySerializerbeforeUseTayra()and Tayra wraps it automatically; every[BinaryEvent]is then protected. - Per event type - wrap explicitly:
opts.Events.UseBinarySerializer<MyEvent>(new TayraBinaryEventSerializer(inner, tayra)).
PII fields are encrypted inside the bdata blob exactly as they are in JSONB, and crypto-shredding works unchanged.
Binary PII is guarded, not silent
If an event type carries [PersonalData] and uses a binary serializer that is not a TayraBinaryEventSerializer, UseTayra() throws at startup rather than writing cleartext to mt_events.bdata. The TAYRA011 analyzer also flags [PersonalData] on a [BinaryEvent] type at compile time. To opt a binary event out of encryption, remove the [PersonalData] annotation.
Flat-table projections
FlatTableProjection writes event member values straight into SQL columns via a generated upsert function. That path never passes through TayraSerializer, and StatementMap<T>.Map(...) only accepts a member expression (x => x.Email), so there is no seam to encrypt a flat-table column - and encrypting one would defeat its purpose, since flat tables exist to be queried in SQL.
There is no such thing as a flat column that is both the readable PII value and protected at rest. The choices are mutually exclusive:
| Column holds | Readable value? | SQL-queryable? | Protected at rest? |
|---|---|---|---|
The PII member (x.Email) | yes | yes | no - cleartext |
A blind-index companion (x.EmailIndex) | no (one-way HMAC) | equality only | yes |
| A Tayra-encrypted blob | no (until app-side decrypt) | no | yes |
So the strategy is to keep personal data out of flat tables entirely:
- The flat table carries non-PII columns plus blind-index (HMAC) companions for equality search. Map
x => x.EmailIndex, neverx => x.Email. The hash gives you search without the value - it is not a protected copy of the email. - The actual PII value stays in the encrypted JSONB document projection. Look it up by id (optionally found via the flat table's blind-index column) when you need the value.
Flat-table PII is not protected
Mapping a [PersonalData] member into a flat-table column stores it in cleartext, and Tayra cannot prevent this at runtime - there is no encryption seam for flat tables. The TAYRA007 analyzer flags this at compile time. Map a blind-index companion instead, or keep the data in the encrypted JSONB projection.
Duplicated fields
Marten's duplicated fields copy a document member into its own indexed relational column. Marten populates that column by reading the value off your .NET object, outside TayraSerializer, so:
- Never duplicate a
[PersonalData]member. Because Tayra encrypts the field in place during serialization, the duplicated column ends up holding non-deterministic ciphertext - unqueryable (the whole point of duplication) and not decryptable on read. TheTAYRA008analyzer flags[DuplicateField]andDuplicate(x => x.PiiMember)on a personal-data member. - Duplicate the blind-index companion instead. The companion (
EmailIndex) is a deterministic HMAC that Tayra writes onto your object before Marten reads it, so the duplicated column is correctly populated and fully queryable:
public class Customer
{
[DataSubjectId] public string Id { get; set; }
[PersonalData, BlindIndex] public string Email { get; set; }
public string EmailIndex { get; set; } = ""; // companion, Tayra fills it
}
opts.Schema.For<Customer>().Duplicate(x => x.EmailIndex); // ✅ indexed, queryable, no plaintextA Where(x => x.EmailIndex == hash) then hits the indexed column while the email stays encrypted in the JSONB.
Index vs Duplicate
If you only need query speed (not a materialized relational column for joins/external SQL), you don't need a duplicated field at all - the HMAC is already in the JSONB, so opts.Schema.For<Customer>().Index(x => x.EmailIndex) indexes data->>'EmailIndex' directly.
Shredded Events in Projections
If a data subject has been crypto-shredded, their events will contain replacement values instead of the original data. Your projections should handle these gracefully - for example, by checking for known replacement values or empty strings.
Multi-tenancy
Tayra offers two models for per-tenant key isolation (see Multi-tenancy):
- Ambient tenancy (
TenantAwareKeyStore+ a settableITenantProvider) for request- and handler-scoped work. - Data-embedded tenancy (tenant in the
[DataSubjectId]value plusDeriveMasterKeyIdFromSubjectPrefix()) for background processing.
The async projection daemon needs data-embedded tenancy
Marten serializes projected documents on a background consumer with a default-tenant root session and no seam to inject a tenant, so ambient tenancy cannot reach the async daemon or a projection rebuild. Tayra logs a startup warning when TenantAwareKeyStore is combined with a store that has async-lifecycle projections. Use data-embedded tenancy for those workloads, and enable RequireTenant to fail loudly instead of silently masking PII. See Marten async daemon and projection rebuilds.
Blind Indexes
Tayra's blind index support works transparently with Marten. Blind index services are registered automatically by AddTayra() when [BlindIndex] attributes are present, so TayraSerializer automatically computes HMAC blind indexes on companion properties before encrypting PII fields during serialization.
Define a model with [BlindIndex] on encrypted fields:
public class CustomerDocument
{
public Guid Id { get; set; }
[DataSubjectId]
public string SubjectId { get; set; } = "";
[PersonalData, BlindIndex(Transforms = ["lowercase", "trim"])]
public string Email { get; set; } = "";
public string? EmailIndex { get; set; } // auto-populated
}Query by blind index using LINQ:
var hash = await blindIndexer.ComputeHashAsync(
"alice@example.com", "EmailIndex", typeof(CustomerDocument));
var customer = await session.Query<CustomerDocument>()
.Where(c => c.EmailIndex == hash)
.FirstOrDefaultAsync();For efficient queries, add a Marten computed index on the companion property:
opts.Schema.For<CustomerDocument>().Index(x => x.EmailIndex);See Blind Indexes for the full guide including transforms, compound indexes, and security considerations.
Data Migration
If you're adding Tayra to an existing application with pre-existing cleartext documents, use the Marten migration service to bulk-encrypt them. See the Brownfield Adoption guide for step-by-step instructions.
Register the migration service and set up a raw store:
// Register Tayra Marten migration services (requires AddTayra() first)
var migrationServices = new ServiceCollection();
migrationServices.AddTayra(opts => opts.LicenseKey = licenseKey);
migrationServices.AddTayraMartenMigrations();
var migrationProvider = migrationServices.BuildServiceProvider();// Create a "raw" store WITHOUT UseTayra() - reads cleartext field values as-is.
// This is required so the migration service can detect which documents need encrypting.
var rawStore = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
// Do NOT call opts.UseTayra() here - we need raw access to read cleartext
});Then encrypt existing documents and verify:
// Bulk-encrypt existing cleartext documents in batches
var migrationService = migrationProvider.GetRequiredService<ITayraMartenMigrationService>();
var result = await migrationService.EncryptExistingDocumentsAsync<CustomerDocument>(
rawStore,
batchSize: 100);
Console.WriteLine($"\n=== Marten Migration Result ===");
Console.WriteLine($" Scanned: {result.TotalScanned}");
Console.WriteLine($" Encrypted: {result.Encrypted}");
Console.WriteLine($" Skipped: {result.Skipped}");
Console.WriteLine($" Errors: {result.Errors}");
Console.WriteLine($" Duration: {result.Duration.TotalMilliseconds:F0}ms");// Verify all documents are now properly encrypted
var verification = await migrationService.VerifyDocumentEncryptionAsync<CustomerDocument>(
rawStore,
batchSize: 100);
Console.WriteLine($"\n=== Marten Verification Result ===");
Console.WriteLine($" Verified: {verification.TotalVerified}");
Console.WriteLine($" Valid: {verification.Valid}");
Console.WriteLine($" Invalid: {verification.Invalid}");
Console.WriteLine($" Duration: {verification.Duration.TotalMilliseconds:F0}ms");
foreach (var invalid in verification.InvalidRows)
{
Console.WriteLine($" [INVALID] Document {invalid.EntityId}, Property: {invalid.PropertyName} - {invalid.Reason}");
}Events
The document migration above has an event-store counterpart, ITayraMartenEventMigrationService, for historical events that were written as cleartext before their type was marked [PersonalData]. Register it with the same AddTayraMartenMigrations() call, then run it against the same raw store (no UseTayra()):
// Bulk-encrypt historical CLEARTEXT events written before the event type was marked [PersonalData].
// This rewrites the mt_events.data column in place (only that column) on the SAME raw store.
var eventMigrationService = migrationProvider.GetRequiredService<ITayraMartenEventMigrationService>();
var eventResult = await eventMigrationService.EncryptExistingEventsAsync<CustomerRegisteredEvent>(
rawStore,
batchSize: 100);
Console.WriteLine($"\n=== Marten Event Migration Result ===");
Console.WriteLine($" Scanned: {eventResult.TotalScanned}");
Console.WriteLine($" Encrypted: {eventResult.Encrypted}");
Console.WriteLine($" Skipped: {eventResult.Skipped}"); // upcast-target and binary events are skipped
Console.WriteLine($" Errors: {eventResult.Errors}");// Verify every persisted event of this type now carries valid Tayra wire-format encryption.
var eventVerification = await eventMigrationService.VerifyEventEncryptionAsync<CustomerRegisteredEvent>(
rawStore,
batchSize: 100);
Console.WriteLine($"\n=== Marten Event Verification Result ===");
Console.WriteLine($" Verified: {eventVerification.TotalVerified}");
Console.WriteLine($" Valid: {eventVerification.Valid}");
Console.WriteLine($" Invalid: {eventVerification.Invalid}");EncryptExistingEventsAsync<TEvent> rewrites the immutable event log in place. It reads mt_events with raw SQL (never through Marten's read pipeline, which would crypto-shred a never-encrypted event on read), encrypts the personal data out-of-band, computes any blind-index companions, and writes back only the data column. The version, seq_id, stream_id, id and timestamp are never touched. It is idempotent and resumable: already-encrypted events are detected and skipped, so re-running is safe.
Raw store required. The store must be configured without UseTayra(). Passing a Tayra-enabled store throws InvalidOperationException - a plain deserialize through a TayraSerializer would decrypt-and-redact a never-encrypted event and destroy the historical PII.
No projection rebuild needed. Decrypting a migrated event yields the identical object a projection already saw, so projections replay unchanged. Prefer running the migration during a quiet window (async daemon paused); a half-migrated table stays readable, but a clean window is safest.
Multi-tenancy. The tenantId parameter narrows the SQL filter to one tenant (required for database-per-tenant). When it is null, all tenants are processed and each event is still encrypted under its own row's tenant_id. For an ambient TenantAwareKeyStore, register the settable AsyncLocalTenantProvider with the parameterless AddTayraMultiTenancy() (or use DataEmbedded) so each row's key is prefixed correctly; the migration service resolves that provider automatically and scopes every row by its own tenant.
v1 limitations.
- Upcast targets are skipped. If
TEventis registered as an upcast target, its on-disk rows are still in the old schema under the source alias; re-serializing them would corrupt the upcaster. Such types are counted asSkippedand logged, not migrated. - Binary events are skipped. Rows whose payload lives in
bdata(binary events, Tayra.Marten9) are counted asSkippedand logged. Encrypt binary events at write time with the binary event serializer instead; migrating existing binary rows is a follow-up.
See Also
- Marten Coverage Reference - What's protected at every Marten surface, and at what level
- Getting Started - End-to-end encryption tutorial
- Wolverine Integration - Message pipeline encryption
- EF Core Integration - Entity Framework Core integration
- Key Stores - Production key store options
