Upgrade Guide
Version-by-version upgrade steps, newest first. Each section lists what breaks the build, what changes behavior at runtime with no compile signal, and what you may want to adopt once you are on the new version. For the full list of what changed, see the changelog; for the deprecation window and what has already been removed, see Versioning & Compatibility.
2.9.x to 2.10.0
No wire format change and nothing breaks the build. One runtime change: where the license key can come from.
Licensing: the key is found without code
Applies if you do not set opts.LicenseKey in code, or set it to a value that can be empty.
Until 2.9.x the only source of the key was opts.LicenseKey. From 2.10.0, when it is not set, Tayra also reads the Tayra:LicenseKey configuration value, the TAYRA_LICENSE_KEY environment variable, and the per-user license file (~/.tayra/license.key, or %LOCALAPPDATA%\Tayra\license.key on Windows). A host that used to fail at startup for want of a key now starts if any of those holds a genuine one. A key set in code still wins, so an application that always sets it sees no change. See Setting your license key.
2.8.x to 2.9.0
No wire format change. One new analyzer warning can fail a build that treats warnings as errors, a blind index that was storing no hash may start storing one, and the default audit logger writes its per-record events at Debug.
Blind indexes: TAYRA018 on a name or companion set alone
Applies if you set IndexName or IndexPropertyName on [BlindIndex] or [ArrayBlindIndex], or call WithIndexName(...) in the fluent API.
Today the index name and the companion member default from one another. From Tayra 3.0 they will be independent, each defaulting to {PropertyName}Index. 2.9.0 changes nothing at runtime, but it reports the configurations that will mean something else:
[BlindIndex(IndexName = "Login")]onEmail, on a type that has aLoginmember: the hash is stored inLogintoday and would be stored inEmailIndexfrom 3.0. AddIndexPropertyName = "Login".[BlindIndex(IndexPropertyName = "EmailHash")]or the same on[ArrayBlindIndex]: the index is calledEmailHashtoday and would be calledEmailIndexfrom 3.0. AddIndexName = "EmailHash".- Fluent
BlindIndex(x => x.Email).WithIndexName("Login")withoutStoredIn(...), on a type that has aLoginmember: logged as a warning at startup, under theTayra.BlindIndexcategory. Add.StoredIn(x => x.Login).
Each fix is the value the message names, and none of them changes where a hash is stored or what an index is called, so no recompute is needed. If your build sets TreatWarningsAsErrors, make these changes when you upgrade, or downgrade the rule until you do (dotnet_diagnostic.TAYRA018.severity = suggestion, see suppressing analyzers).
Blind indexes: an IndexName that names no member now uses {PropertyName}Index
Applies if a [BlindIndex] sets IndexName (or a fluent one calls WithIndexName(...)) to a name that no member of the type has, without IndexPropertyName or StoredIn(...).
Until 2.8.x such an index had no companion and stored no hash; TAYRA005 warned about it. From 2.9.0 the hash is stored in {PropertyName}Index when the type has that member. If you were filling that member yourself, Tayra now overwrites it on every write. Rows written before the upgrade have no value there until they are written again or you run a recompute.
Audit logging: per-record events move from Information to Debug
Applies if you use the default audit logger (you have not registered your own ITayraAuditLogger or the persistent audit trail) and you read, ship or alert on its Tayra audit: lines.
From 2.9.0 KeyAccessed, KeyCreated, DataEncrypted, DataDecrypted and CryptoShreddingDetected are logged at Debug, under the category Tayra.Audit.Records. A host that logs at Information no longer emits them. Key deletions and expiries, data subject exports, breach reports, migrations and blind index recomputes are still logged at Information, and IntegrityCheckFailed is now logged at Warning.
If you depend on the per-record lines, for example as your record of processing, put them back:
- raise the event types you need in
AuditLogging.Levels(opts.AuditLogging.Levels[TayraAuditEventType.DataEncrypted] = LogLevel.Information), or - set the
Tayra.Audit.Recordscategory toDebugin your logging configuration.
See Audit trail. For a record that does not depend on log levels or log retention, use the persistent audit trail, which is unaffected.
Licensing: the missing-key error is logged at startup or first encrypt, not on resolve
Applies if you alert on, or search logs for, the No license key provided or License validation failed error lines.
Until 2.8.x they were logged at Error as soon as Tayra's services were resolved. From 2.9.0 resolving logs Debug (no key) or Warning (a key that is not valid), and the Error line, with the same text as before, is logged once when the host fails to start or the first encryption is refused. An application that runs without its key still logs the error and still fails; a job that only builds the container no longer logs it.
Key creation: a read-back that misses is retried for up to 3 seconds
Applies to every key store. Nothing to change unless you relied on the immediate failure.
Until 2.8.x, a key that could not be read straight after it was created failed the operation at once. From 2.9.0 the read is retried for KeyStoreReadBackTimeout (3 seconds by default) first. On a strongly consistent store the only case that reaches the retries is a key deleted in the same instant it was created, such as a crypto-shred racing the first write for a subject: that still fails with the same exception, about 3 seconds later. Set KeyStoreReadBackTimeout = TimeSpan.Zero to keep the old timing. See Reading a new key back.
New in 2.9.0 that you may want
[BlindIndexTenant]andEntity<T>(e => e.BlindIndexTenant(x => x.Member))name the member a type's blind index tenant is read from, instead of routing every type through oneBlindIndexTenantResolver. See Blind indexes across tenants.
2.7.x to 2.8.0
No wire format change and nothing breaks the build. Three runtime changes: what Marten stores, when a missing license key is reported, and how soon a key rotation made elsewhere is seen.
Marten: encrypted non-string members are no longer stored as a placeholder
Applies if you use Tayra.Marten8 or Tayra.Marten9 with Marten's System.Text.Json serializer and have non-string [PersonalData] members (int, bool, DateOnly, an enum, ...).
Until 2.7.x the stored document held both the companion and the member at its type default ("houseNumber": 0 next to "houseNumberEncrypted": "..."). From 2.8.0 only the companion is stored. Loading through Marten is unchanged, for old and new documents alike, and there is nothing to migrate: a document loses its placeholder the next time it is saved.
Check these before you upgrade:
- Anything that reads the stored JSON without Tayra, such as raw SQL on
data ->> 'houseNumber', a BI export, or another service deserializing the row. The key is now absent rather than zeroed. - Rolling back. Tayra 2.7.x reads the new shape, except for a C#
requirednon-string member: System.Text.Json rejects a document whose required key is missing, and only 2.8.0 lifts that. If you haverequiredmembers and may need to roll back, turn the change off first. - Mixed versions during a rolling deploy hit the same
requiredcase: a 2.7.x instance cannot load a document that a 2.8.0 instance wrote.
To keep the previous shape, set OmitEncryptedMemberPlaceholders = false (Marten: non-string members).
Licensing: a missing key is reported at startup and on encrypt, not on resolve
Until 2.7.x, resolving ITayra or IFieldEncrypter threw TayraLicenseException when the key was missing or invalid. From 2.8.0 resolving always succeeds, so that jobs which only build the container (codegen write, db-apply) run without a key.
Nothing changes for an application that runs: the host still fails to start without a genuine key, and TayraHost.Create still throws. What to check:
- Code or tests that resolve
ITayrato detect a missing key. UseLicenseChecker.IsAuthenticinstead. - Processes that use the container without starting a host, such as a console tool built on a bare
ServiceCollection. Without a key they now get as far as their first encryption, which throws. Decryption in such a process is not gated. - Workarounds for the old behavior, such as building
TayraSerializerlazily or depending onIBlindIndexerinstead ofITayra, are no longer needed.
The Compliance services follow the same rule. Resolving ITayraCompliance or one of its services with an Essentials key, or with no key, no longer throws; the first call on it does. If you relied on resolution failing to detect the wrong edition, check LicenseChecker.LicenseInfo.Edition instead.
Key rotation from another process is seen after KeyCacheDuration
Applies if you rotate data keys from a different process or instance than the one that writes (the CLI, an admin job, another pod).
Until 2.7.x every encrypt asked the key store for the current key version, so a rotation was used by every instance on its next write. From 2.8.0 the current version is cached with the key for KeyCacheDuration (default 5 minutes), which removes a key store round trip from every write. An instance that did not perform the rotation keeps encrypting with the previous version until its entry expires. Nothing becomes unreadable: the previous version is kept.
If you rotate and then re-encrypt immediately, wait KeyCacheDuration between the two, or restart the writers, so the re-encrypt pass does not miss records written in between. Rotating and writing in the same process needs no change.
New in 2.8.0 that you may want
Bulk key creation for rebuilds and imports. If a rebuild writes documents for many subjects that have no key yet, call
EnsureKeysForSubjectsAsync<T>per batch of subject ids before writing them (Marten: creating keys in bulk).PostgreSQL key store: skip the connection reset. If only Tayra uses the key store's data source, set
NoResetOnClose = trueto save a round trip per call. The Aurora stores and the PostgreSQL master key store already do (PostgreSQL: connection reset).Documents-only protection for Marten. If you installed
TayraSerializerby hand to keep Tayra away from your events, switch toUseTayra(opts => opts.ProtectEvents = false). You get the startup guard back, and annotated events are left alone, which a hand-installed serializer does not do (Marten: documents first, events later).Blind index tenants when tenants share a master. If your subject-id prefix is a master bucket and you put the tenant into every indexed value to keep tenants apart, set
BlindIndexTenantResolver(andBlindIndexMasterPrefixResolverto stay on the bucket masters), remove the workaround, and run a blind index recompute (Blind indexes across tenants).
2.6.x to 2.7.0
No wire format change. What may need action:
- Envelope encryption: the master role's default storage and its new read-only default.
- AWS key stores: the move to AWS SDK v4.
- ASP.NET Core with
AddTayraJson(): responses are now encrypted. - Ambient multi-tenancy: records written while tenants shared cached keys may need repair.
- A hand-written
ITayraimplementation: it no longer compiles until it adds one method.
Everything else in 2.7.0 is either a fix that needs nothing from you or an addition you can adopt when it suits you.
If you worked around UseTayra() failing on Marten 9.33 or later by wrapping the serializer yourself (options.Serializer(new TayraSerializer(options.Serializer(), tayra))), switch back to UseTayra(): the workaround skips the startup guards that UseTayra() installs.
Envelope encryption: master keys have their own default storage
Every With*MasterKey now defaults the master role to its own table or prefix instead of inheriting the data role's, so master keys can never be stored alongside the data keys they wrap (see Keeping master keys apart from data keys).
| Backend | Master role default before | Master role default now |
|---|---|---|
| PostgreSQL, AWS Aurora, SQLite | table tayra_encryption_keys | table tayra_master_keys |
| HashiCorp Vault | path prefix tayra | path prefix tayra-master |
| AWS Secrets Manager | name prefix tayra/ | name prefix tayra-master/ |
| AWS Parameter Store | path prefix /tayra/ | path prefix /tayra-master/ |
| Azure Key Vault | key prefix tayra- | key prefix tayramaster- |
If you set the master's table or prefix explicitly, nothing changes.
If you relied on the old default, your existing master keys are still under the old name, and 2.7.0 looks for them under the new one. Before you upgrade, do one of the following:
Master in its own database, vault or account (nothing else shares that storage): keep the old name by setting it explicitly, for example
WithPostgreSqlMasterKey(cs, o => o.TableName = "tayra_encryption_keys").Master in the same storage as the data role (the configuration 2.7.0 now refuses): move the master keys to the new default. For PostgreSQL, create the new table by starting once against an empty environment or with
AutoMigrate, then move the rows:sqlINSERT INTO public.tayra_master_keys SELECT * FROM public.tayra_encryption_keys WHERE key_id LIKE 'tayra:master:%'; DELETE FROM public.tayra_encryption_keys WHERE key_id LIKE 'tayra:master:%';Adjust the
LIKEpattern if you changedKeyIdTemplateor use a custom master key id resolver. For the other backends, copy each master secret to the new prefix and then delete the old one.
Skipping this does not lose data, but it stops decryption: 2.7.0 finds no master under the new name, so existing data keys cannot be unwrapped until the masters are where it looks.
AWS key stores: AWS SDK for .NET v4
Tayra.KeyStore.AwsSecretsManager, Tayra.KeyStore.AwsParameterStore and Tayra.KeyStore.AwsAurora now depend on AWS SDK v4 (>= 4.0, < 5.0). If your application is still on SDK v3, restore reports a version conflict; move the application to SDK v4. Their configuration and stored data are unchanged.
Envelope encryption: the master role is read-only
IMasterKeyStore.DestroyAsync now throws unless the master role opts in. If your application (or an admin tool) destroys master keys to erase an organization, set AllowDestroy = true on its With*MasterKey options, ideally only in that admin process:
.WithPostgreSqlMasterKey(masterCs, o => o.AllowDestroy = true)Because the master role no longer lists keys, you can also remove list permissions from its credentials (on AWS Secrets Manager, secretsmanager:ListSecrets), and delete permissions wherever AllowDestroy stays off.
ASP.NET Core: AddTayraJson() now encrypts responses
AddTayraJson() used to register the converter factory and stop there. In an ASP.NET Core app it now also adds it to the minimal API and MVC JSON options, so request bodies are decrypted and responses carry ciphertext for [PersonalData] members (System.Text.Json).
If you already added the factory to those options yourself, nothing changes: the converter is added once. If your API served those types as plaintext on purpose, or masks them with response scrubbing, opt out, or clients start receiving ciphertext:
// Keep ASP.NET Core responses in plaintext, for example when the app masks them with
// Tayra.AspNetCore's response scrubbing instead.
services.AddTayraJson(opts => opts.ConfigureAspNetCore = false);Console and worker apps are unaffected.
Ambient multi-tenancy: records written under another tenant's key
Before 2.7.0, under ambient tenancy (TenantStrategy.Ambient or AddTayraMultiTenancy), the in-process key caches ignored the tenant. For the cache lifetime (five minutes by default) after one tenant loaded a key, a second tenant asking for the same key id got the first tenant's key. Two kinds of key id collide:
- Blind index keys always did, because every tenant uses the same
bi:{scope}. Records written in that window carry an index computed with another tenant's HMAC key, so searches in their own tenant miss them. - Data keys did only when two tenants used the same subject id (
42in both). A record written in that window was encrypted under the other tenant's DEK, and on a host with a cold cache it reads back as if shredded.
If you do not use ambient tenancy, or your subject ids are globally unique (GUIDs, or ids that carry the tenant), only the blind index case applies: run a blind index recompute in each tenant's scope after upgrading.
If subject ids repeat across tenants, also find the affected records before recomputing: a record whose fields read back masked in its own tenant, while its tenant's DEK exists, was written under another tenant's key. Decrypt it in the scope of the tenant whose key it used (the tenant that shares its subject id), then save it again in its own tenant's scope, which encrypts it under the right key. Nothing is lost: the other tenant's key still decrypts it.
Data-embedded tenancy (TenantStrategy.DataEmbedded) was not affected.
Custom ITayra implementations: one new member
ITayra gained ComputeBlindIndexForTenantAsync(value, indexName, entityType, tenant, ct). A class that implements ITayra itself, such as a hand-written test fake or a decorator, must add it: forward it to the inner instance, or throw NotSupportedException if it never serves tenant-partitioned indexes. Mocks generated by a mocking library need no change. The new members on IBlindIndexer and IBlindIndexKeyProvider have default implementations, so custom implementations of those still compile.
New in 2.7.0 that you may want
None of these change anything until you opt in.
- Per-tenant blind index keys. Under DataEmbedded tenancy,
PartitionBlindIndexKeys = truegives each tenant its own HMAC keys, so the same value in two tenants no longer links. It changes every existing index value, so recompute right after enabling it, and move queries to the tenant-taking methods (Blind indexes across tenants). - Accent-insensitive blind indexes. The
foldtransform makesMüllermatchMuller. Adding it to an index is a transform change, so recompute that index. - Serilog through DI.
AddTayraSerilog()now reaches loggers configured withReadFrom.Services(services). If you attached the policy by hand, you can drop that code (Serilog). - TAYRA002 suppressions. A
[DataSubjectId]whose personal data sits only in[DeepPersonalData]members no longer triggers TAYRA002, so suppressions added for that can go.
2.5.0 to 2.6.0
Nothing to migrate, no wire format change, no configuration to update, and nothing that can fail your build unless you called an API that never shipped in a release.
Marten 9.20+ users should upgrade
Tayra.Marten9 2.5.0 and earlier throw MissingMethodException on every document read against Marten 9.20 or newer. Marten relocated most of ISerializer onto Weasel.Storage.IStorageSerializer mid-9.x, and the older builds call those members where they used to live. Writes look fine, so the first symptom is a failing query. 2.6.0 binds those members at runtime and works across the whole 9.x line. Nothing already stored is affected, and there is no migration.
The rest is licensing.
The rule is now absolute: decryption is never gated by the license, under any circumstances.DecryptAsync performs no license check at all, and no code path in Tayra can refuse a decryption for licensing reasons. For production keys this was already true; what changes is everything around it.
Two behaviour changes, both in the same direction (fewer ways to be locked out):
An expired trial key now starts the host and keeps decrypting. Previously it stopped both operations and the host refused to start, so restarting a pod after a trial lapsed made trial-encrypted data unreadable. Now the trial term stops
EncryptAsynconly. If you were relying on an expired trial to halt an application at startup, checkLicenseChecker.IsLicensedyourself at the point you want to stop.Startup fails only on a key that is not genuine - absent, tampered with, unverifiable, or malformed. That part is unchanged and still hard-fails, exactly as before. What no longer fails startup is a key that verifies but has run out of term.
If you call the licensing API directly:
LicenseChecker.EnsureCanDecrypt()is removed. It was added during the 2.x licensing work and never appeared in a release, so no shipped version exposed it. Delete the call; there is nothing to replace it with, because there is nothing to ask.- New
LicenseChecker.IsAuthentictells you whether the key is genuine - the startup gate.IsLicensedkeeps its meaning: genuine and usable for new encryption, which is also what the Compliance edition features check.
2.4.0 to 2.5.0
One change, and it affects your build rather than your data. No wire format change, no migration, no configuration to update.
The analyzers never ran before 2.5.0
Tayra.Analyzers shipped its assembly in the package's lib/ folder rather than analyzers/dotnet/cs, so NuGet never loaded it. No TAYRA rule fired in any build up to and including 2.4.0, whether you installed Tayra.Core alone or added Tayra.Analyzers explicitly. Tayra.Core now carries the analyzers itself and they work.
The rules are therefore not new, they are newly functioning. Expect a wall of diagnostics on your first build, including rules that have existed since 1.x. Every one describes a condition that was already in your code. TAYRA013 is the one to read first: it reports a non-string [PersonalData] member with no companion, which means that field has been persisting in cleartext.
Two practical steps:
- Remove any
PackageReferencetoTayra.Analyzers. The package is no longer published; the rules arrive throughTayra.Core. - Triage the diagnostics before suppressing anything. If you build with warnings as errors, expect the first build to fail. The analyzer reference explains each rule and its fix.
2.3.x to 2.4.0
2.4.0 makes [PersonalData] work on any member type, adds multi-tenancy that survives background processing, and adds bulk key prefetch and event migration for Marten. Three of its changes need action from you before the upgrade is safe.
Upgrade the whole fleet together
Non-string PII is now written in a v4 wire format that Tayra 2.3.x cannot read. Both formats stay readable on 2.4.0 (the decoder is chosen by the version byte), so your existing data keeps decrypting. But once a node running 2.4.0 encrypts a field, a node still on 2.3.x cannot decrypt it. Rolling the library version back after writing is not supported. Upgrade every node together and take a backup first if you need a rollback path.
This affects non-string members only. [PersonalData] strings and string collections are unchanged, and so are envelope-wrapped DEKs.
Before you start
- Back up. Standard practice for any change that rewrites stored PII.
- Plan a single-version fleet. See the warning above. Mixed 2.3.x/2.4.0 nodes writing the same non-string fields will produce data the older nodes cannot read.
- Read step 2 before you deploy. Two of the runtime changes mean data you believed was protected was not, and finding that out during a deploy is worse than finding it out now.
Step 1: what breaks the build
These surface as compiler errors or warnings when you bump the package version. If you build with warnings as errors, treat the obsolete warnings as errors too.
Coming from 2.3.x you will land on 2.5.0
Read the 2.4.0 to 2.5.0 section as well. The analyzer rules named below did not actually run until 2.5.0, so you will meet all of them at once.
TAYRA013: a non-string [PersonalData] member has no companion
A non-string member (int, DateOnly, an enum, a record) cannot hold ciphertext in place, so its ciphertext goes to a companion byte[]? member named {Member}Encrypted, or whatever you set via EncryptedFieldName / .StoredIn(...). If that companion is missing or misnamed, this is now a compile-time error, and encryption and decryption throw an InvalidOperationException naming the member.
public class Patient
{
[DataSubjectId] public string Id { get; set; } = "";
[PersonalData] public int Bsn { get; set; } // Error TAYRA013: no companion
// Add:
public byte[]? BsnEncrypted { get; set; }
}If TAYRA013 fires, that field was never encrypted
In 2.3.x a missing or misnamed companion (a typo like BsnEncryped was enough) produced a warning log, the field was skipped, and the value was persisted in cleartext. Treat any data already written for that member as compromised plaintext, not as data that merely needs re-encrypting.
TAYRA014: Masking on a non-string member
Masking is a deliberate partial-cleartext leak and is string-only. On a non-string member it is now a compile-time error; use ReplacementValue instead. In the fluent API the WithMask* methods only exist on a string member, so the equivalent mistake does not compile at all.
TAYRA015: cleartext comparisons in query predicates
New warning. It fires where a [PersonalData] or [BlindIndex] member is compared against a value inside a query predicate, which silently matches zero rows because the comparison runs against the stored ciphertext. If you build with warnings as errors, this breaks the build; if you do not, read the warnings anyway, because each one is a query that has never returned a correct result.
// Warning TAYRA015
session.Query<Patient>().Where(x => x.Bsn == 123456789)
// Fix
var match = await blindIndexer.BuildPredicateAsync((Patient x) => x.Bsn, 123456789);
session.Query<Patient>().Where(match)In-memory comparisons (a lambda converting to Func<,>, over objects Tayra has already decrypted) and comparisons against null are not reported.
TAYRA006 now means something different
It used to report [BlindIndex] on any non-string member as unsupported. That is no longer true, so it now warns on a bool or a small enum, where a deterministic index has so few distinct hashes that frequency analysis recovers the plaintext immediately. [BlindIndex] on an int BSN is now correct and silent; [BlindIndex] bool HivPositive now warns.
See Roslyn Analyzers for the full rule set.
IMasterKeyIdResolver.Resolve() takes a context
If you registered a custom master-key id resolver for envelope encryption, update the signature:
Before:
public string Resolve() => $"tayra:master:{_tenants.GetCurrentTenantId()}";After:
public string Resolve(MasterKeyContext context) => $"tayra:master:{_tenants.GetCurrentTenantId()}";MasterKeyContext.KeyId carries the DEK id being wrapped, which is what lets you derive the master key from the data instead of from ambient state. That is the whole point of the change: see Deriving the master key from the subject id. If your resolver only reads ambient state, adding the parameter is the entire migration.
Obsolete and removed symbols
| Symbol | Replace with |
|---|---|
[SerializedPersonalData] | [PersonalData] - it dispatches on member type now |
EntityTypeBuilder.SerializedPersonalData(...) | PersonalData(...) |
PersonalDataBuilder.WithMaskValue(string) | WithReplacement(TProp), strongly typed to the member |
[PersonalData].MaskValue | ReplacementValue |
SerializedPersonalDataBuilder<T> (removed) | The generic PersonalDataBuilder<T, TProp> |
BinaryFieldSerializer.Serialize() (removed) | Nothing writes v3 any more. Deserialize() is kept permanently so v3 ciphertext stays readable |
The obsolete entries still compile and behave identically; move at your convenience.
Step 2: what changes at runtime with no compile signal
Non-string members now encrypt, and old rows stay cleartext
In 2.3.x, [PersonalData] on a value type was skipped at runtime. From 2.4.0 it is serialized and encrypted into its companion. Two consequences:
- New writes are protected. Existing rows are not. Tayra does not retroactively encrypt what is already stored, so rows written before the upgrade keep their cleartext value until something rewrites them. Run a brownfield migration over those entity types, or for Marten events see event migration.
- EF Core needs a column for the companion. Add the
byte[]?companion to your model and generate a migration. Document stores (Marten, MongoDB) absorb the new member into the JSON with no schema change.
[BlindIndex] on a non-string member: the companion column is empty
In 2.3.x the indexer read the member as a string, which is null for an int or a DateOnly, and skipped it. The companion stayed null on every row, so every query on that index matched zero rows and nothing errored. TAYRA006 was only a warning, so it compiled and shipped.
Non-string members are now indexed properly, and a value that cannot be canonicalized throws instead of quietly producing an unusable index. If you had [BlindIndex] on a non-string member, recompute the indexes for those documents after upgrading.
Existing string indexes are byte-identical (strings are still hashed as themselves), so they need no reindex.
Key store failures now throw a typed exception
A key store outage during encryption previously let the raw store error propagate, and it could surface as something misleading downstream (Marten's 42883: operator does not exist: text = integer was the reported case). It now throws TayraKeyStoreUnavailableException, naming the key id and operation, with the store failure preserved as InnerException. Key resolution runs before any field is touched, so the object is unmodified and no half-encrypted data reaches the database.
If you catch PostgresException (or your store's native exception type) around Tayra calls, catch TayraKeyStoreUnavailableException instead.
PostgreSQL AutoMigrate creates the schema
AutoMigrate now issues CREATE SCHEMA IF NOT EXISTS before CREATE TABLE, fixing 3F000: schema "..." does not exist on a fresh database with a non-default Schema. The runtime role needs CREATE on the database for first-run schema creation. If it cannot have that, set AutoMigrate = false and provision the schema through your own pipeline.
Marten logs a startup warning for multi-tenant async projections
If you combine a TenantAwareKeyStore with a store that has async-lifecycle projections, Tayra now logs a startup warning. This is not a new failure, it is a pre-existing one becoming visible: Marten serializes projected documents on a background consumer with no ambient tenant, so ambient tenancy cannot reach the daemon and DEKs would be stored under the wrong prefix. See Marten async daemon and projection rebuilds for the supported pattern.
Step 3: the v4 wire format
Non-string [PersonalData] payloads are now UTF-8 JSON (v4) rather than the v3 binary encoding. You do not need to run anything:
- Both formats stay readable. The decoder is chosen by the version byte on the data, so v3 ciphertext written by 2.3.x keeps decrypting indefinitely.
BinaryFieldSerializer.Deserializeis kept permanently for exactly this reason. - Fields migrate lazily. A field becomes v4 the next time it is encrypted. No migration job, no rebuild.
- The change is one-way. See the warning at the top of this section.
A side effect worth knowing: any type System.Text.Json can serialize now works on a non-string member. In 2.3.x a hand-rolled binary serializer supported exactly eleven types and threw NotSupportedException at first encrypt for anything else, with no compile-time warning, so a plausible member like short Age or TimeSpan compiled clean and failed in production. short, byte, uint, ulong, char, TimeOnly, TimeSpan, Uri, records, classes and collections now all work. DateTime keeps its Kind and DateTimeOffset keeps its offset.
Step 4: what you may want to adopt
None of these are required by the upgrade.
- Strongly typed PII stays strongly typed. You no longer need to migrate
intBSNs,DateOnlydates of birth or enum members to strings to encrypt them, and blind indexes work on all of them. See[PersonalData]. - Per-field shred replacement values.
[PersonalData(ReplacementValue = "-1")]writes a specific value back after crypto-shredding instead of the type default. The fluent form,e.PersonalData(x => x.Bsn).WithReplacement(-1), is typed to the member with no string parsing. - One-call multi-tenancy.
.WithMultiTenancy(...)wires the tenant provider, theTenantAwareKeyStoredecorator, envelope master-key resolution and strict mode coherently in one place. See configure once. - Strict mode.
RequireTenantturns a null tenant into aTayraTenantRequiredExceptioninstead of silently storing keys unprefixed. If you run multi-tenant, turn this on. See strict mode. - Wolverine flows the tenant automatically. With the settable
AsyncLocalTenantProviderregistered alongsideUseTayra(), the tenant flows fromEnvelope.TenantIdinto message encryption and the handler scope, with noBeginScopecall. - Event migration for Marten.
ITayraMartenEventMigrationServicebulk-encrypts historical events written before their type was marked[PersonalData]. See event migration. - Bulk key prefetch.
ICryptoEngine.PrefetchKeysAsyncand the turnkeyRebuildProjectionWithPrefetchAsync<TProjection>()collapse the per-subject key lookups a large projection rebuild would otherwise issue. See bulk key prefetch. BuildPredicateAsyncfor blind-index queries. Builds the query predicate so no query site has to name the companion member or the index. See querying.UseTayra()covers Marten ancillary stores. A store registered viaAddMartenStore<TStore>is now protected by the same singleUseTayra()call; previously its[PersonalData]members were silently ignored.
Verification checklist
- Build is clean, including TAYRA013, TAYRA014 and TAYRA015.
- Every TAYRA015 warning has been resolved, not suppressed: each one is a query that silently matched nothing.
- Every non-string
[PersonalData]member has itsbyte[]?companion, and EF Core models have a migration for it. - Custom
IMasterKeyIdResolverimplementations takeMasterKeyContext. - Blind indexes on non-string members have been recomputed.
- Data written before the upgrade for previously-skipped non-string members has been migrated, or you have accepted that it stays cleartext.
- Every node runs 2.4.0 before any node writes non-string PII.
-
catchblocks around Tayra calls handleTayraKeyStoreUnavailableException.
