Skip to content

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.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):

  1. 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 EncryptAsync only. If you were relying on an expired trial to halt an application at startup, check LicenseChecker.IsLicensed yourself at the point you want to stop.

  2. 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.IsAuthentic tells you whether the key is genuine - the startup gate. IsLicensed keeps 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:

  1. Remove any PackageReference to Tayra.Analyzers. The package is no longer published; the rules arrive through Tayra.Core.
  2. 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

  1. Back up. Standard practice for any change that rewrites stored PII.
  2. 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.
  3. 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.

csharp
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.

csharp
// 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:

csharp
// 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

SymbolReplace with
[SerializedPersonalData][PersonalData] - it dispatches on member type now
EntityTypeBuilder.SerializedPersonalData(...)PersonalData(...)
PersonalDataBuilder.WithMaskValue(string)WithReplacement(TProp), strongly typed to the member
[PersonalData].MaskValueReplacementValue
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.Deserialize is 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 int BSNs, DateOnly dates 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, the TenantAwareKeyStore decorator, envelope master-key resolution and strict mode coherently in one place. See configure once.
  • Strict mode. RequireTenant turns a null tenant into a TayraTenantRequiredException instead of silently storing keys unprefixed. If you run multi-tenant, turn this on. See strict mode.
  • Wolverine flows the tenant automatically. With the settable AsyncLocalTenantProvider registered alongside UseTayra(), the tenant flows from Envelope.TenantId into message encryption and the handler scope, with no BeginScope call.
  • Event migration for Marten. ITayraMartenEventMigrationService bulk-encrypts historical events written before their type was marked [PersonalData]. See event migration.
  • Bulk key prefetch. ICryptoEngine.PrefetchKeysAsync and the turnkey RebuildProjectionWithPrefetchAsync<TProjection>() collapse the per-subject key lookups a large projection rebuild would otherwise issue. See bulk key prefetch.
  • BuildPredicateAsync for 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 via AddMartenStore<TStore> is now protected by the same single UseTayra() 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 its byte[]? companion, and EF Core models have a migration for it.
  • Custom IMasterKeyIdResolver implementations take MasterKeyContext.
  • 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.
  • catch blocks around Tayra calls handle TayraKeyStoreUnavailableException.

See Also