Skip to content

Changelog ​

All notable changes to Tayra are recorded here, newest first. Tayra follows Semantic Versioning: major versions may break the public API, minor versions add backward-compatible features, and patch versions are bug fixes only.

The framework major lives in the package name (Tayra.Marten8 / Tayra.Marten9, Tayra.Wolverine5 / Tayra.Wolverine6); the version below is Tayra's own suite version, shared across every package. See Installation.

2.7.0 - 2026-09-25 ​

Upgrading from 2.6.x

See the upgrade guide. No wire format change; check it if you use envelope encryption, the AWS key stores, AddTayraJson() in ASP.NET Core, ambient multi-tenancy, or implement ITayra yourself.

Fixed ​

  • Marten no longer leaves ciphertext in your documents, and a second write no longer encrypts twice. TayraSerializer encrypted the caller's object in place and never put it back, so after SaveChangesAsync the instance held ciphertext. Writing the same instance again (a second SaveChangesAsync, an identity-map session, a retried daemon batch, a cached aggregate) encrypted the ciphertext a second time, which then read back as base64. A value-type member such as an int or DateOnly fared worse: the first pass reset it to its default, and the second encrypted that default over the real value. Only the written JSON carries ciphertext now; the object is restored exactly as it was, from a record of what encryption overwrote rather than by decrypting again. If encryption fails part way, what it already encrypted is restored too. The same fix applies to TayraBinaryEventSerializer, and the Wolverine serializer, which already restored by decrypting, now uses the same mechanism (#19).

    Data written by earlier versions is unaffected unless the same instance was written twice. A string member written that way decrypts to Tayra ciphertext rather than its value; that inner ciphertext is intact, so decrypting it once more recovers the value. A value-type member written that way cannot be recovered from the document, because the second write encrypted its default; rebuild it from its source, such as the event stream behind a projection.

  • EF Core: a failed save no longer leaves ciphertext in your tracked entities.TayraSaveChangesInterceptor encrypted the tracked entities before saving and decrypted them only after a successful save, so when SaveChanges failed (a constraint violation, a concurrency conflict, a transient error) the entities kept their ciphertext. Saving again, by the application or by an execution strategy such as EnableRetryOnFailure, then encrypted the ciphertext a second time, and the stored value read back as base64. The interceptor now restores each entity exactly when the save ends, whether it succeeded, failed, or was canceled, and an encryption that fails part way through the tracked entities restores those it already encrypted. Stored data is affected only where a failed save was retried on the same entities; recover string members as described for Marten above.

  • MongoDB and System.Text.Json: a failed write no longer leaves ciphertext in your objects. The same pattern as EF Core: TayraMongoCollection (InsertOneAsync, InsertManyAsync, ReplaceOneAsync) and the Tayra JSON converter decrypted the object only after a successful write, so a failed write (a duplicate key, a timeout, a serialization error) left ciphertext behind, and writing again encrypted it twice. Both now restore the object exactly when the write ends, however it ends, and InsertManyAsync restores the documents it already encrypted if encrypting a later one fails.

  • The EF Core data migration refuses a context with Tayra's interceptor.EncryptExistingDataAsync encrypts each row itself and then saves. Given a context configured with UseTayra(), the save interceptor encrypted every row again, so the migration stored all of them encrypted twice. The guide already said to pass a raw context; the migration now throws instead of trusting that, as the Marten migration does for a Tayra-enabled store.

  • A [PersonalData] member configured as a Marten duplicated field now fails closed. Marten copies a duplicated field into its column straight from the object, outside the serializer. The column used to receive ciphertext only because the object was left encrypted, which the fix above removes, so it would now receive plaintext. UseTayra()'s startup guard throws for any duplicated personal-data member the store knows about, and the serializer throws on the first write of a document type that duplicates one by attribute. Analyzer TAYRA008 already warned about this configuration; duplicate the blind-index companion instead.

  • A record with a null [DataSubjectId] is no longer blanked on read as if shredded. Decrypt treated a missing subject like a shredded key: every [PersonalData] string came back as its replacement value and a CryptoShreddingDetected audit event was raised, so a record stored in the clear before its subject was known lost its data on read. Encryption never writes ciphertext without a subject, so a record without one is now returned exactly as stored, with no shred event. A record whose subject was removed after encryption now reads back as its stored ciphertext rather than the replacement value (#20).

  • Key retention could delete a master key when the master and data roles shared a table. Every With*MasterKey inherited its data store's defaults, so pointing both roles at the same database (or vault, or account) put master keys and the data keys they wrap in one table or prefix. Key retention runs on the data role, so it offered the masters for deletion once they were old enough, and deleting a master makes every data key under it unrecoverable. Three changes keep them apart: every master role now defaults to its own table or prefix (see Keeping master keys apart from data keys); Tayra refuses to start when both roles are configured onto overlapping storage; and the data role never offers a non-envelope row to retention and refuses to delete one, whatever the configuration (#21).

  • Ambient multi-tenancy: tenants no longer share cached keys. The in-process key caches were keyed on the logical key id, which under ambient tenancy is the same for every tenant: the same bi:{scope} for a blind index, and the same subject id when subject ids are not globally unique. For the cache lifetime (5 minutes by default) after one tenant loaded a key, every other tenant asking for the same id got that tenant's key. For blind indexes, the same value in two tenants produced the same index, linking records across tenants, and a tenant's own HMAC key was never created, so its searches stopped matching once the cache expired. For encryption, a tenant whose subject id matched another tenant's was encrypted under the other tenant's DEK, its own DEK was never created, and a fresh host read its data back as shredded. A warm cache also let a call with no tenant through when RequireTenant is on. The caches are now keyed on the id each key is actually stored under, and a missing tenant fails closed on every path.

    Data-embedded tenancy (TenantStrategy.DataEmbedded) was not affected. Records written while this bug applied are recoverable; the upgrade guide shows how.

  • UseTayra() works on Marten 9.33 and later. Marten 9.33 moved the binary event serializer contract to JasperFx.Events.IEventBinarySerializer and changed the type of EventGraph.DefaultBinarySerializer and EventMapping.BinarySerializer. Tayra.Marten9 compiled its accessors against the older shape, so UseTayra() threw MissingMethodException while the store was being built, and with it the serializer guard and the binary-PII guard were lost for anyone who worked around it. The binary event wiring now binds those members at runtime, as the document serializer has since 2.6.0, and TayraBinaryEventSerializer accepts a serializer that implements only the JasperFx contract. The same kind of compile-time call in the event migration service, which would have failed its first read on Marten 9.20 and later, is bound at runtime too. Tayra's compatibility tests now build stores against Marten 9.39 and resolve every Marten member Tayra.Marten9 references, so the next relocation fails Tayra's build instead of yours (#17).

  • The AWS key stores work on AWS SDK for .NET v4. Tayra.KeyStore.AwsSecretsManager, Tayra.KeyStore.AwsParameterStore and Tayra.KeyStore.AwsAurora were built against SDK v3, and their dependency range let an application on v4 resolve v4 anyway. SDK v4 made model value types nullable, so the first call into a changed member threw MissingMethodException. They now build against SDK v4 with the range capped below 5.0, so a mismatch is a NuGet warning at restore rather than a runtime failure. They also cope with v4 returning null rather than an empty list when there is nothing to list (#23).

  • AddTayraSerilog() now reaches the logger. It registered the destructuring policy in the container and nothing attached it, so a logger configured through DI redacted nothing. The policy is now also registered as a Serilog IDestructuringPolicy, so the standard UseSerilog((ctx, services, cfg) => cfg.ReadFrom.Services(services)) setup attaches it, and the new Destructure.WithTayra(services) attaches it explicitly (Serilog).

  • TAYRA001 no longer warns on nested [DeepPersonalData] payload types. A type whose [PersonalData] members are reached through a [DeepPersonalData] member is encrypted under its holder's subject and must not have a [DataSubjectId] of its own, yet the analyzer warned on every such type, so the documented nesting pattern could not be written without a suppression. It now skips a type reached through [DeepPersonalData] anywhere in the project, including through a collection member, a derived type, or a generic payload. A payload declared in a different project from all of its holders still warns there and needs a suppression, as the [DeepPersonalData] page shows (#18).

  • TAYRA002 no longer flags a subject whose personal data is nested. A type with a [DataSubjectId] and only [DeepPersonalData] members, which is exactly the fix TAYRA003 recommends, was reported as having an unused subject id. [DeepPersonalData] members now count as personal data for TAYRA002.

Added ​

  • Per-tenant blind index keys. A blind index key was shared by every tenant, so the same value (a surname, a national id) produced the same index in every tenant, and anyone who could read the index columns could join a person's records across tenants that must be isolated. With DataEmbedded tenancy, WithMultiTenancy(t => t.PartitionBlindIndexKeys = true) gives each tenant its own HMAC keys, taken from the subject id as the master key is, so it works in the Marten async daemon. Each key is stored as {tenant}:bi:{scope} and wrapped by that tenant's master, so erasing a tenant takes its index keys with it. Queries name the tenant (ComputeBlindIndexForTenantAsync, ComputeBlindIndexesForTenantAsync, IBlindIndexer.BuildPredicateForTenantAsync, and EF Core's ComputeBlindIndexForTenantAsync<TEntity>); a query without one throws. Mark a cross-tenant lookup such as a login email SharedAcrossTenants, and rotate one tenant's key with IBlindIndexKeyProvider.RotateKeyForTenantAsync. Turning it on changes existing index values, so run a recompute afterwards (#24).

  • fold blind index transform for accent-insensitive search: Müller matches Muller, Søren matches Soren, Straße matches Strasse. It covers the letters that plain Unicode decomposition misses (ø, ß, æ, ł, đ, þ), and splits the Dutch ij into ij. It works from a fixed table rather than string.Normalize, so a globalization-invariant container folds exactly as a full-ICU host does. Use it from attributes (Transforms = ["lowercase", "fold"], or the new BlindIndexTransforms.Fold constant) or the fluent WithFold() (#16).

  • [DataSubjectId(AllowMissing = true)] (fluent: .AllowMissing()) stores a record whose subject id is null as it is, with that group's personal data unencrypted, instead of throwing. For records whose subject is backfilled later, where a placeholder subject would give the person a second identity that erasure could miss. Each such write raises a new StoredWithoutSubject audit event and increments the new tayra.encrypt.subject_missing metric (charted on the Compliance Grafana dashboard), and the PII inventory reports which subjects allow it. The default is unchanged: a null subject still fails closed (#20).

  • FieldEncrypterReflectionHelper.EncryptForSerializationAsync encrypts an object for the length of a serializer write and returns a handle that restores it when disposed. Use it in any custom serializer wrapper instead of EncryptAsync.

Changed ​

  • AddTayraJson() wires the converter into ASP.NET Core. The docs said it did; it only registered the converter factory. In an ASP.NET Core app it now adds the factory to the minimal API and MVC JSON options, so request bodies are decrypted and responses encrypted. An app that called AddTayraJson() for other serialization and served plaintext responses will now serve ciphertext: set TayraJsonOptions.ConfigureAspNetCore = false to keep the old behavior, which you also want when masking responses with response scrubbing. Console and worker apps are unaffected, and Tayra.Json still does not depend on ASP.NET Core (upgrade guide).

  • Envelope encryption: every master role defaults to its own table or prefix. See the upgrade guide before upgrading if your master relied on the old default: existing masters stay under the old name until you move them or set it explicitly.

  • The AWS key stores require AWS SDK for .NET v4 (>= 4.0, < 5.0). See the upgrade guide.

  • The envelope master role is read-only by default. IMasterKeyStore.DestroyAsync throws unless the With*MasterKey options set AllowDestroy = true: destroying a master erases the whole organization, and a misdirected call should not be able to. Enable it only where erasure is intended, ideally in a separate admin process (read-only master role).

  • ITayra has a new member, ComputeBlindIndexForTenantAsync. A class that implements ITayra itself, such as a test fake or a decorator, must add it; see the upgrade guide. The new members on IBlindIndexer and IBlindIndexKeyProvider have default implementations.

  • The envelope master role no longer needs a list permission. A master's versions are found by checking :v1, :v2, and so on for existence instead of listing, and destroy removes the highest version first so the remaining versions stay contiguous. On AWS Secrets Manager the master role therefore no longer needs ListSecrets, which cannot be scoped to a resource and exposed every secret name in the account.

  • The startup error for a missing or invalid license key no longer reads as a paywall. All six licensing refusals shared one message telling the reader to "purchase or request a trial license". That is right for an encrypt or edition refusal and wrong for the authenticity gate, which is the one licensing failure an operator can meet while their data is completely intact: a secret that fails to mount stops reads without touching a key or a byte of ciphertext, and the message sent them to a pricing page instead of to their secret store.

    The startup and construction gates now say what is actually true, leading with the data being safe and the fix being local: this is the license key and not your encryption keys, everything already encrypted is untouched and stays decryptable, the key does not need to be within its term, restore it from your secret store or backups. It also distinguishes a key that never arrived from one that arrived and did not verify, because those have different remedies. Encrypt and edition refusals keep the original wording.

Documentation ​

  • Every C# example in the docs is now compiled. Examples are pulled from sample projects (mostly the new compile-only samples/Tayra.Samples.Docs, which also runs the Tayra analyzers), so they are checked against the current API on every build. Only examples that must not compile stay inline: code that triggers an analyzer, "before" code in the upgrade guide, and one signature listing. Compiling them turned up examples that did not work as written, now corrected:

    • MongoDB: there is no TayraMongoCollectionFactory; wrap a collection with collection.WithTayra(tayra, options). The wrapped methods take Builders<T>.Filter filters, not bare lambdas.
    • Marten blind index and recompute setup: opts.UseTayra() inside AddMarten does not exist (use services.UseTayra()), and the recompute services need AddTayraEFCoreMigrations() / AddTayraMartenMigrations(); they are not registered automatically.
    • The fluent array blind index example left Emails unencrypted (no PersonalData), and several [BlindIndex] examples lacked [PersonalData] or a companion member.
    • Key stores: the IKeyStore listings lacked GetManyAsync, and the UseVaultKeyStore / UseAzureKeyVaultKeyStore / SQLite non-DI calls used overloads that do not exist. ICryptoEngine is public, not internal.
    • Compliance: method names (GenerateInventory, ExportAccessAsync, ExportInventoryAsJson), the TayraComplianceOptions name, and the key lifecycle report's audit query now match the API.
    • [DataSubjectId] accepts any type with a stable ToString(), including int; TAYRA002 does not check the type, as the page claimed.
  • Blind index key management described the HMAC key as bi:{fieldHash}, one key per field derived from the type, property, and transforms. It is bi:{scope}, and the default scope is "default", so unless you set Scope every blind index in the application shares one key. The page now says so, and the rotation example in recompute passes the scope name rather than bi:customer_email, which rotated a key named bi:bi:customer_email.

  • Named custom transforms are now documented. opts.BlindIndex.RegisterTransform(...) has always let an attribute reference your own transform by name, but the docs only showed the fluent WithTransform(), which read as if attributes were limited to the built-ins (#16).

  • The transforms page now warns that the transform list is part of the stored index: changing it silently stops existing values matching until you recompute.

  • alphanumeric is ASCII-only, so it deletes accented letters (Müller becomes Mller). Its XML documentation said it stripped "non-alphanumeric characters", which suggests the opposite. The XML documentation and the docs now say so, and point to ["fold", "alphanumeric"] for names.

  • Back Up Your License Key - the key is a link in your recovery chain, and the only one Radarleaf issues. An expired key starts and decrypts, but an absent one stops the host before it reads anything, so it belongs with your key store backups. Sets out the two recovery paths side by side: through Tayra, which needs the key, and straight from the wire format specification, which needs nothing but your key store.

  • The XML documentation on TayraLicenseException claimed decryption "never throws this". True of DecryptAsync, misleading in effect, since resolving IFieldEncrypter throws it and reads then stop. It now describes the real boundary, so the remaining gap is visible to anyone auditing the guarantee that no code path refuses a decryption.

  • The XML documentation on TayraOptions.LicenseKey said a key was "not required in development environments". It never was optional: a missing key fails host startup in every environment, and IHostEnvironment is not consulted. The comment now says a trial or production key is required everywhere, matching the options and licensing pages (#22).

2.6.0 - 2026-08-10 ​

Upgrading from 2.5.0

See the upgrade guide. Licensing only - nothing to migrate, and every change removes a way to be locked out of your own data.

Fixed ​

  • Tayra.Marten9 threw MissingMethodException on every document read against Marten 9.20 and newer. Marten moved most of ISerializer onto Weasel.Storage.IStorageSerializer (which Marten.ISerializer now inherits) during the 9.x line. Source compiles against either version, so nothing warned at build or restore time, but TayraSerializer was compiled against the older assembly and its calls named members that no longer existed where it looked for them. Writes appeared to succeed - Marten's write path happens to use one of the four members that did not move - so the failure showed up on the first query.

    TayraSerializer now resolves every member it delegates to at runtime, against whichever interface the running Marten declares it on, so one package serves the whole 9.x line. It also implements WriteToParameter(DbParameter, object), a member the newer contract added and Tayra did not have, which was a hole a write could have passed through unencrypted. If a future Marten changes the contract again, Tayra now fails at startup naming the member instead of at first read.

    Reported as issue #15 against 2.5.0 on Marten 9.22.5. Tayra.Marten8 is unaffected: Marten 8's serializer contract is identical from 8.35.0 through 8.37.4.

Changed ​

  • Decryption is no longer gated by the license under any circumstances. The runtime licensing gate now sits on the encryption path only. DecryptAsync performs no license check of any kind, and there is no longer a code path anywhere in Tayra that can refuse a decryption for licensing reasons - not a lapsed term, not an expired trial, not a clock. Previously an expired trial key stopped decryption as well as encryption; production keys were already unconditional.

  • An expired trial key now starts the host and keeps decrypting. Startup validation now fails only when the key is absent, tampered with, unverifiable, or malformed - the cases where the key is not genuine. A genuine key always starts, however far past its term, because a process that refuses to boot cannot decrypt either. An expired trial is refused per-call on EncryptAsync instead, with an error that says decryption is unaffected. Anything you encrypted while evaluating stays readable.

    If you relied on an expired trial halting an application at startup, that no longer happens; check LicenseChecker.IsLicensed explicitly if you want that behaviour.

Added ​

  • LicenseChecker.IsAuthentic and LicenseValidationResult.IsAuthentic - whether the key itself is genuine (signature verified, payload well-formed), as distinct from IsLicensed, which additionally means "usable for encrypting new data". IsAuthentic is the startup gate; IsLicensed governs encryption and the edition-gated Compliance features.

  • LicenseValidationResult.Expired(license, error) - the result for a genuine key whose term has run out, carrying the parsed LicenseInfo so callers can report which key and which date.

Removed ​

  • LicenseChecker.EnsureCanDecrypt(), added during the 2.x licensing work and never part of a release, is gone rather than deprecated: a method that answers "may I decrypt?" is exactly the thing that must not exist. EnsureCanEncrypt() is unchanged and remains the only runtime gate.

Documentation ​

  • Wire Format Specification - the normative, byte-exact description of everything Tayra writes, published so that encrypted data can be recovered with nothing but your key store and any AES-256-GCM implementation: no Tayra assemblies, no license key, no involvement from Radarleaf. Covers the 0x03 and 0x04 payload layouts, the associated-data construction (the part a hand-written decryptor cannot guess), the TAYRA_M: masking prefix, key id derivation and rotation suffixes, the key store table layout, and the 0xE1 envelope format for wrapped DEKs. Decryption only; it is not a specification for producing ciphertext.

  • Reference decryptors in .NET and Python, neither of which references any Tayra package. The .NET one uses only the base class library, the Python one only the cryptography package, and both are maintained as single runnable files that decrypt the published test vectors and report. The spec page also publishes those vectors inline: known-good ciphertext with its keys and expected plaintexts, so a reimplementation in any language can be validated against the same cases. Both implementations are wired into the docs as snippets rather than copied, and the snippet plugin now scans .py sources as well as .cs.

  • WireFormatSpecTests guards the specification itself: the documented byte offsets, the associated-data string, the 0xE1 envelope layout, the published vectors, and a run of the .NET recovery script as a customer would invoke it. CI runs the Python script the same way, so both published recovery paths are enforced. A change that would invalidate the published document now fails the build naming the part that moved.

  • The wire format diagram on Encryption showed the 0x01 and 0x02 layout while the text described 0x03 as what field encryption writes, and the stated 31-byte overhead was given as 29. Both corrected.

  • LICENSE §1(a), §1(d), §1(e) and the contract templates now state that no license check runs on decryption at all, and that only an inauthentic key can fail startup.

2.5.0 - 2026-08-08 ​

Upgrading from 2.4.0

See the upgrade guide. One thing changes, and it changes your build rather than your data.

Security ​

  • The analyzers were packaged so that they never ran. No TAYRA rule fired in any consumer build up to and including 2.4.0. Tayra.Analyzers shipped its assembly in the package's lib/ folder, where NuGet treats it as an ordinary library reference; analyzers are only loaded from analyzers/dotnet/cs. No package depended on it either, so the documented experience - install Tayra.Core, get the rules - delivered nothing, and neither did installing Tayra.Analyzers explicitly. Tayra.Core now carries the analyzers itself and they load correctly.

    Every compile-time guard described in the 2.4.0 notes and throughout the docs was inert. In particular, TAYRA013 could not have warned you about a non-string [PersonalData] member whose companion was missing, which is the case where the value was persisted in cleartext. On your first build after upgrading, expect diagnostics you have never seen before; each one describes a condition that already existed in your code. Read them before suppressing any.

Removed ​

  • The standalone Tayra.Analyzers package is no longer published. The analyzers now ship inside Tayra.Core, so there is nothing left for a separate package to deliver, and installing both would load two copies of the same assembly and report every diagnostic twice. Every rule is about Tayra attributes and is useless without Tayra.Core, so nothing needs it on its own. If you reference Tayra.Analyzers explicitly, remove the PackageReference; the rules arrive through Tayra.Core with no configuration.

2.4.0 - 2026-08-08 ​

Superseded by 2.5.0

Install 2.5.0. Everything below shipped in 2.4.0 and is accurate, but the analyzers it describes were packaged so that they never loaded, so none of the compile-time guards worked until 2.5.0.

Upgrading from 2.3.x

See the upgrade guide for the ordered steps: what breaks the build, what changes behavior with no compile signal, and the one-way wire format change. Coming from 2.3.x you will land on 2.5.0, so read the 2.4.0 to 2.5.0 section as well.

Security ​

  • A missing companion member no longer persists PII as plaintext. A non-string [PersonalData] member (int, DateOnly, an enum, a record, ...) is serialized and its ciphertext written to a companion byte[]? member, because a value type cannot hold ciphertext in place. If that companion was absent or misnamed (a typo like BsnEncryped was enough), Tayra logged a warning, skipped the field, and left the value in cleartext for the caller to persist. Encryption and decryption now throw an InvalidOperationException naming the member, and analyzer TAYRA013 reports it as a compile-time error.

    If you hit TAYRA013 or this exception when upgrading, that field was never being encrypted. Treat the data already written to your database as compromised plaintext.

Added ​

  • Event-store encryption migration for Marten. A new ITayraMartenEventMigrationService (registered by AddTayraMartenMigrations(), in Tayra.Marten8 / Tayra.Marten9) bulk-encrypts historical events that were written as cleartext before their type was marked [PersonalData] - the counterpart of the existing document migration. EncryptExistingEventsAsync<TEvent> rewrites the immutable mt_events log in place using raw SQL (never through Marten's read pipeline, which would crypto-shred a never-encrypted event on read), encrypting personal data out-of-band, computing blind-index companions, and writing back only the data column - version, seq_id, stream_id, id and timestamp are never touched. It runs against a raw store (no UseTayra(); a Tayra-enabled store is rejected), is idempotent/resumable (already-encrypted events are skipped), is tenant-correct (each row is encrypted under its own tenant_id), and needs no projection rebuild afterward. VerifyEventEncryptionAsync<TEvent> reports any event still holding cleartext PII. v1 limitations: upcast-target event types and binary (bdata) events are counted as skipped and logged, not migrated. See Marten: event migration.

  • Bulk key prefetch for large projection rebuilds. A new IKeyStore.GetManyAsync(keyIds) retrieves many keys in one batched round trip (returning a map of found key id to bytes; absent or shredded ids are simply omitted), and ICryptoEngine.PrefetchKeysAsync(keyIds) warms the in-memory key cache from it so a subsequent per-document decrypt hits the cache instead of the store. This collapses the millions of single-row key lookups a Marten rebuild would otherwise issue - one per subject - into a handful of bulk reads. Fast backends override with a single query (= ANY(...) on PostgreSQL/Aurora, chunked IN (...) on SQLite, a dictionary scan in-memory); the envelope and tenant-aware decorators pass the batch through; stores with no native multi-get (Vault, cloud secret stores) fall back to a per-key loop. Both new members ship with default implementations, so existing custom stores and engines keep working unchanged. Prefetch is best-effort and never throws. See Marten: bulk key prefetch for large rebuilds.

  • Rolling-window prefetch pacer for very large Marten rebuilds. An opt-in overload RebuildProjectionWithPrefetchAsync<TProjection>(PrefetchPacing pacing, ...) (in Tayra.Marten8 / Tayra.Marten9) warms a bounded window of subject keys just ahead of the async daemon and evicts windows it has already passed, so the warmed working set stays bounded for rebuilds spanning tens of millions of subjects where warming everything up front is too much memory. It follows the daemon via its progress tracker and paces windowed SELECT DISTINCT subject scans against the observed seq_id. Best-effort: the rebuild is authoritative and any pacer failure is caught and logged without breaking it. v1 targets the single-shard case (conjoined multi-tenancy or single-tenant); for per-tenant-partitioned or database-per-tenant projections it logs a warning and falls back to warm-all-then-rebuild. Two new cache-only Core primitives back it: ICryptoEngine.EvictCachedKeys(keyIds) and IFieldEncrypter.EvictCachedKeysForSubjects<TEntity>(subjectIds) (also on ITayra), the inverse of the prefetch members, which remove keys from the in-memory cache without deleting them from the store (not crypto-shredding). Both Core members ship with default/no-op behavior so existing custom engines keep working. The simple warm-all overload is unchanged. See Marten: rolling-window pacer.

  • Turnkey projection prefetch for Marten rebuilds. New IServiceProvider extensions RebuildProjectionWithPrefetchAsync<TProjection>() and PrefetchKeysForProjectionAsync<TProjection>() (in Tayra.Marten8 / Tayra.Marten9) discover a projection's handled event types, scan mt_events for the distinct [DataSubjectId] values those events carry, warm the keys in chunks, and (for the first) rebuild the projection - all in one call, no hand-built key id strings. A new subject-id-based Core surface, IFieldEncrypter.PrefetchForSubjectsAsync<TEntity>(subjectIds) (also on ITayra), takes domain ids and derives the base key ids from the entity's [DataSubjectId] metadata (Prefix and Group). This is turnkey (one call), not transparent per-page batching; it is best for full rebuilds where the subject set is knowable, targets the DataEmbedded / single-tenant model, and warms base (version 0) keys only. See Marten: turnkey projection prefetch.

  • One-call multi-tenancy setup: .WithMultiTenancy(...) on the Tayra builder. A single fluent entry point (chained after .UseXxxKeyStore() and .WithXxxMasterKey(...)) picks a tenancy model and wires everything coherently: the tenant provider, the TenantAwareKeyStore decorator, envelope master-key resolution, strict mode, and integration auto-flow, with the correct decorator ordering. Two strategies: TenantStrategy.Ambient (default, request/handler-scoped, settable AsyncLocalTenantProvider or a custom ITenantProvider via the generic overload) and TenantStrategy.DataEmbedded (background-safe, master key derived from the subject-id prefix, requires envelope mode). Fail-closed by default: RequireTenant is seeded to true, and for Ambient + envelope it is propagated to the envelope resolver in the same call, fixing the footgun of setting it in two places. The lower-level AddTayraMultiTenancy() / DeriveMasterKeyIdFromSubjectPrefix() building blocks remain for advanced use. See Multi-tenancy: configure once.

  • Multi-tenancy now works in background contexts (Marten async daemon, projection rebuilds, Wolverine handlers). A new data-derived master-key resolver, opts.DeriveMasterKeyIdFromSubjectPrefix(), takes the tenant/org from the DEK key id itself (e.g. orgA from orgA:patient1), so per-tenant envelope isolation holds with no ambient tenant. The shipped AsyncLocalTenantProvider (registered via the new parameterless AddTayraMultiTenancy()) lets background code set the tenant per unit of work with BeginScope(tenantId), and opt-in strict mode (RequireTenant, on both TayraMultiTenancyOptions and EnvelopeOptions) throws TayraTenantRequiredException instead of silently sharing one master key. Breaking (EA minor): IMasterKeyIdResolver.Resolve() is now Resolve(MasterKeyContext context) - update any custom resolver. See Multi-tenancy.

  • Wolverine handlers now flow the tenant automatically. When the settable AsyncLocalTenantProvider is registered (parameterless AddTayraMultiTenancy()) alongside UseTayra(), the tenant flows from Envelope.TenantId into message-body encryption/decryption and into the handler's ambient scope for its whole duration, so Marten session work inside a handler (outbox and tenant sessions included) keys off the message tenant with no BeginScope call. No behavior change when the settable provider is not registered. See Wolverine multi-tenancy.

  • Marten startup advisory for multi-tenant async projections. When a TenantAwareKeyStore is combined with a store that has async-lifecycle projections, Tayra now logs a startup warning: the async projection daemon serializes on a background consumer with no ambient tenant, so ambient tenancy cannot reach it. The advisory points to data-embedded tenancy (DeriveMasterKeyIdFromSubjectPrefix()) and RequireTenant. See Marten multi-tenancy.

  • UseTayra() now covers Marten ancillary stores automatically. Previously it decorated only the default AddMarten() store, so a store registered via AddMartenStore<TStore> kept its own serializer and [PersonalData] on its types was silently ignored. Tayra's serializer wrapper is now registered as a global Marten configuration, so a single UseTayra() protects the default store and every ancillary store, with no per-store opt-in. See Marten integration.

  • AWS Secrets Manager keystore supports a custom endpoint and DI-supplied client. A new ServiceUrl option targets a custom endpoint (e.g. LocalStack) for dev/CI parity, and the envelope master keystore now prefers an IAmazonSecretsManager registered in DI (for example via AddAWSService<IAmazonSecretsManager>()) - matching the data keystore - so the whole key topology can run against LocalStack without a real AWS account. See AWS Secrets Manager.

  • [PersonalData] is now the single field-level PII attribute and dispatches on member type. A string (or string collection) is encrypted in place as before; any other type (int, DateOnly, decimal, Guid, enums, records, ...) is JSON-serialized and its ciphertext is stored in a companion byte[]? member automatically (named {Member}Encrypted, or set via the attribute's EncryptedFieldName / the fluent .StoredIn(...)). You no longer choose the mechanism - Tayra infers it from the type - and you do not need to migrate strongly-typed members to strings. [DeepPersonalData] is unchanged.

  • Any type System.Text.Json can serialize now works on a non-string member. Previously 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, alongside the types that already did. DateTime keeps its Kind and DateTimeOffset keeps its offset. An exotic type System.Text.Json cannot handle now fails with a clear message naming the member and pointing at a custom JsonConverter.

  • Per-field crypto-shred replacement values. [PersonalData(ReplacementValue = "-1")] writes a specific value back after shredding instead of the type default; the string is parsed into the member's type (invariant culture; enum name or number) once at metadata-build time, so an unparseable value fails fast. The fluent form is strongly typed to the member - e.PersonalData(x => x.Bsn).WithReplacement(-1) takes a value of the member's own type, with no string parsing and no invariant-culture ambiguity.

  • A single generic fluent overload. PersonalData<TProperty>(Expression<Func<T, TProperty>>) binds by inference for any member type (string, string collection, or a value type with its byte[]? companion configured via .StoredIn(x => x.FooEncrypted)).

  • Blind indexes work on any canonicalizable type. [BlindIndex] now supports int, long, DateOnly, DateTime, Guid, enums, and their nullable forms, alongside string. Strongly-typed PII is now queryable without migrating it to strings. Existing string indexes are byte-identical - strings are still hashed as themselves, so no reindex is needed for them.

  • IBlindIndexer.BuildPredicateAsync() builds the query predicate for you, so a query site never has to name the companion member or the index:

    csharp
    var match = await blindIndexer.BuildPredicateAsync((Patient x) => x.Bsn, 123456789);
    var results = await session.Query<Patient>().Where(match).Where(x => x.Active).ToListAsync();

    It returns a plain Expression<Func<T,bool>>, so it composes with other clauses and works on Marten, EF Core, and MongoDB alike. Note that a blind index is an HMAC: equality only, never ranges.

  • Analyzer TAYRA015 catches cleartext comparisons in query predicates. Comparing a [PersonalData] or [BlindIndex] member against a value inside a query predicate (==, !=, .Equals(...) in a lambda that converts to Expression<Func<T, bool>>, or the equivalent LINQ query syntax) is now a compile-time warning. At rest the member holds ciphertext, or a zeroed default for a non-string member, so the predicate translates to SQL, runs against the ciphertext, and silently matches zero rows - which reads as "no such record" rather than as a bug. The diagnostic names the fix that applies: compare the blind index via IBlindIndexer.BuildPredicateAsync, or add [BlindIndex] first if the member has none. A comparison in a lambda that converts to a plain Func<,> runs in memory over objects Tayra has already decrypted and is not reported, and neither is a comparison against null. See TAYRA015.

  • Analyzer TAYRA014 reports a compile-time error when a Masking strategy is set on a non-string [PersonalData] member. Masking is string-only (a deliberate partial-cleartext leak); use ReplacementValue for non-string members. In the fluent API the WithMask* methods only exist on a string member, so masking a non-string member does not compile at all.

Changed ​

  • Licensing is now a single-term model (breaking). One signed expiresAt replaces MaintenanceUntil; production decryption is perpetual, encryption is term + 60-day grace, and pre-term keys must be re-issued. See Licensing.

  • Non-string [PersonalData] payloads are now UTF-8 JSON in a v4 wire format (previously a binary encoding in v3). Both formats remain readable - the decoder is chosen by the version byte on the data - so existing ciphertext keeps decrypting after you upgrade, and a field migrates to v4 lazily the next time it is encrypted. No migration job is required.

    Upgrade is one-way

    A v4 payload cannot be read by Tayra 2.3.x or earlier. Once a node running 2.4.0 encrypts a field, a node still on 2.3.x cannot decrypt it, so rolling the library version back after writing is not supported. Upgrade the whole fleet together, and take a backup first if you need a rollback path.

    This affects serialized (non-string) members only. [PersonalData] strings and string collections are unchanged, and so are envelope-wrapped DEKs.

  • [SerializedPersonalData] and the SerializedPersonalData(...) fluent method are now [Obsolete] aliases of [PersonalData]. The distinction between the two attributes was purely mechanical (non-string PII needs a companion byte[]?), and Tayra now infers it from the member type, so the separate attribute is redundant. The alias behaves identically and existing code keeps compiling; move to [PersonalData] at your convenience. Tayra is in early access, so this lands as a minor rather than going through the deprecation window.

  • .WithMaskValue(...) is renamed to .WithReplacement(...) (the old name is an [Obsolete] alias), and the attribute's MaskValue property is renamed to ReplacementValue (the old name is an [Obsolete] alias). A replacement value is universal (every member type) and leaks nothing; it is kept distinct from Masking, which is string-only and a deliberate partial-cleartext leak.

  • Analyzer TAYRA006 retargeted. It used to report [BlindIndex] on any non-string member as unsupported, which is no longer true. It now warns when [BlindIndex] is applied to a bool or a small enum, where a deterministic index is close to useless: with only two or three distinct hashes in the column, frequency analysis recovers the plaintext immediately. [BlindIndex] on an int BSN is now correct and silent; [BlindIndex] on bool HivPositive now warns.

Removed ​

  • The standalone SerializedPersonalDataBuilder<T> is removed, folded into the generic PersonalDataBuilder<T, TProp>. Its WithGroup, StoredIn, and WithReplacement are now methods on the unified builder. The SerializedPersonalData(...) entry point remains as an [Obsolete] shim returning the unified builder.

  • BinaryFieldSerializer.Serialize is removed. Nothing writes the v3 binary payload any more, so the write half of the frozen encoder is gone. Tayra is in early access, so this lands as a minor rather than going through the deprecation window.

    BinaryFieldSerializer.Deserialize is deliberately kept, and is not deprecated. It is the only thing that can decode a v3 payload, and every serialized (non-string) field encrypted by 2.3.x is v3. Removing it would make that PII permanently undecryptable, so it stays indefinitely. Writing v3 ended; reading v3 has not, and no re-encrypt migration is forced on you.

Fixed ​

  • A key-store outage during encryption now fails closed with a clear TayraKeyStoreUnavailableException. Previously, when the key store was unreachable while resolving a key (for example a missing schema under Marten, in envelope mode), the raw store error (a PostgresException) propagated with nothing in the exception chain naming Tayra or the key store, and could surface downstream as a misleading error (e.g. Marten's 42883: operator does not exist: text = integer). Key resolution runs before any field is touched, so this exception is raised with the object unmodified - no half-encrypted or corrupt data reaches the database. The underlying store failure is preserved as the inner exception.

  • [BlindIndex] on a non-string member silently indexed nothing. 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 (see below), and a value that cannot be canonicalized throws instead of quietly producing an unusable index.

    If you had [BlindIndex] on a non-string member, its companion column is empty. Recompute the indexes for those documents after upgrading; see Recomputing Indexes.

  • PostgreSQL key store AutoMigrate now creates the configured schema. When Schema was set to a non-default value (e.g. "tayra_keys") on a fresh database, AutoMigrate issued CREATE TABLE without first creating the schema, so every key operation failed with 3F000: schema "..." does not exist. Tayra now issues CREATE SCHEMA IF NOT EXISTS before the table. This also covers the master-key store configured via WithPostgreSqlMasterKey. The runtime role needs CREATE on the database for first-run schema creation; set AutoMigrate = false and provision the schema through your own pipeline if it cannot.

2.3.0 - 2026-06-10 ​

Core hardening: encryption now fails closed, tampering is surfaced, and crypto-shredding stays complete after key rotation. Annotations now work on public instance fields as well as properties.

Changed (breaking) ​

  • Encryption fails closed. EncryptAsync now throws an InvalidOperationException when a [DataSubjectId] value is null, or when no encryption key can be resolved for a field's group. Previously the affected fields were silently persisted as plaintext with only a warning log. Decryption tolerance is unchanged: a missing (shredded) key still follows the replacement-value path.
  • The metadata API no longer exposes a PropertyInfo. The reflection model now describes annotated members through a PersonalDataMember accessor (with Name, MemberType, CanWrite, GetValue, SetValue, and Underlying) that backs both properties and fields. PersonalDataFieldInfo.Property becomes PersonalDataFieldInfo.Member, DataSubjectInfo exposes Member, and the blind-index metadata exposes SourceMember / IndexMember. Code that read .Property off the public metadata types must move to the new Member accessor.
  • Non-writable annotated members now fail with a clear error at first use instead of being silently skipped. A member Tayra writes back to (Text and Serialized kinds, plus their companions) must be writable: a settable property (init-only is fine) or a non-readonly, non-const field. A get-only property or a readonly/const field throws an InvalidOperationException at first encrypt. Similarly, read-only string collection instances (e.g. ImmutableList<string>) now throw instead of being silently skipped.
  • The AAD decrypt path rejects legacy v1 ciphertexts unconditionally. Version 0x01 payloads predate associated-data binding, so they cannot be context-verified - a database-level attacker could splice a v1 ciphertext into a different field of the same subject. AesGcmEncryptor.DecryptWithAssociatedData / DecryptStringWithAssociatedData now throw a CryptographicException on v1 payloads; at the field-encrypter level a v1 value is left untouched and surfaces as an IntegrityCheckFailed audit event. The v1 format remains valid only for the no-AAD AesGcmEncryptor.Encrypt / Decrypt primitive pairing.

Added ​

  • All seven attributes now annotate properties or public instance fields.[PersonalData], [DeepPersonalData], [SerializedPersonalData], [DataSubjectId], [BlindIndex], [CompoundBlindIndex], and [ArrayBlindIndex] are recognized on public instance fields in addition to properties; field usage compiles and works. Only public instance members are scanned (private and static members are ignored), mirroring the existing property scan rule. The fluent API expression selectors now accept field expressions (x => x.SomeField) as well as property expressions.
  • Tamper detection is surfaced. A CryptographicException during decryption (tampered ciphertext or context mismatch) is now logged at Warning and emits a new TayraAuditEventType.IntegrityCheckFailed audit event; the field value is left untouched. Legacy-plaintext tolerance (FormatException) stays quiet at Debug.
  • HashSet<string> and other mutable ICollection<string> properties are now encrypted (previously only IList<string> / string[] worked; other collection types were silently skipped). A new fluent overload EntityTypeBuilder<T>.PersonalData(Expression<Func<T, IEnumerable<string>>>) configures string-collection properties directly.

Fixed ​

  • ShredAsync(subjectId) now deletes the subject's base key and every {subjectId}:-prefixed key - rotated versions (:vN) and group keys - so crypto-shredding remains complete after key rotation. Previously only the exact base key was deleted, leaving data encrypted under rotated keys readable. Neighboring subjects that merely share an ID prefix are unaffected.
  • [DeepPersonalData] object graphs with cycles no longer cause a StackOverflowException, and shared (diamond) references are processed exactly once instead of being double-encrypted.
  • DeepPersonalData configured on a collection property via the fluent API now encrypts the elements (previously silently skipped).
  • Eliminated a key-creation race that could encrypt data with a never-persisted key: IKeyStore.StoreAsync is now documented as first-writer-wins, and the crypto engine and blind-index key provider read the key back after storing and serialize per-key-ID creation in-process.
  • Blind-index HMAC key rotation now actually replaces the stored key (delete, store, then read-back verify); previously the first-writer-wins store made rotation a silent no-op. The HMAC key cache also gained a TTL (default 5 minutes) so rotations and deletions performed elsewhere are observed.

2.2.0 - 2026-06-10 ​

Changed ​

  • Updated the integration targets to Marten 9.7.1 and Wolverine 6.6.0. The dependency ranges still cap the next framework major, so a consumer that pulls Marten 10 or Wolverine 7 fails fast at restore instead of at runtime.
  • Wolverine 6 dead-letter exception redaction now uses a supported hook. When RedactExceptionMessages is enabled, Tayra.Wolverine6 scrubs the exception text on Wolverine's IDeadLetterInterceptor (added in WolverineFx 6.6.0) instead of reaching into private exception internals. The redacted dead letter now preserves the original exception type, so you can still filter and triage dead letters by type while the message is replaced with Exception details redacted by Tayra. The behavior of the option is otherwise unchanged. Tayra.Wolverine5 keeps its existing redaction mechanism.

No public API changes.

2.1.0 - 2026-06-09 ​

Added ​

  • Per-major integration packages. Pick the package that matches your framework version: Tayra.Marten8 / Tayra.Marten9 and Tayra.Wolverine5 / Tayra.Wolverine6. Both lines ship from a single trunk at the same suite version, so you are never forced onto a new framework major to stay current with Tayra. See Installation.

Fixed ​

  • HashiCorp Vault key store: keys are now read back correctly from Vault KV v2.
  • Wolverine 5: the integration's source generator is kept enabled so its handlers (such as the built-in GDPR erasure handler) are discovered at runtime.

2.0.0 - 2026-06-07 ​

Tayra 2.0 moves the default integrations onto the current framework majors.

Changed (breaking) ​

  • The Marten integration now targets Marten 9 and the Wolverine integration targets Wolverine 6. Marten 8 / Wolverine 5 support continues through the per-major packages introduced in 2.1.0.
  • The Wolverine integration now encrypts at the message serializer rather than via handler middleware. PII is therefore ciphertext at rest in the durable outbox/inbox and in dead-letter storage (the durable outbox could previously hold cleartext). UseTayraMiddleware() is now an [Obsolete] alias for UseTayra(), and the RedactDeadLetterQueues option was removed because dead-letter bodies are encrypted automatically.

Added ​

  • Searchable encrypted collections via [ArrayBlindIndex], with fluent configuration.
  • New Roslyn analyzers: TAYRA007 (PII on Marten binary events), TAYRA008 (flat-table PII guard), TAYRA009 (duplicated-field PII guard), and TAYRA010 (PII on a Wolverine saga).
  • Marten: fail fast at startup if a custom serializer drops the Tayra wrapper.

Performance ​

  • Core encrypt/decrypt dispatch is cached as compiled delegates.

1.4.0 - 2026-05-05 ​

Added ​

  • Envelope encryption: two-tier encryption with pluggable master and data key stores and pluggable master-key id resolution.
  • AWS Aurora (PostgreSQL with IAM authentication) key store.
  • Persistent, hash-chained audit trail (Marten-backed), plus signed (ECDSA P-256) and scheduled compliance reports with a pluggable archive.
  • Date-only perpetual-fallback licensing model (the major-version gate was dropped).

Changed ​

  • [DeepPersonalData] now recurses transitively through nested annotations.

Removed ​

  • The Tayra.MediatR, Tayra.MassTransit, and Tayra.NServiceBus integrations were dropped because their upstreams moved to commercial or copyleft licensing. Tayra ships no copyleft or commercial-by-default dependencies.

1.3.0 - 2026-05-01 ​

Changed ​

  • Compliance features were extracted into a dedicated Tayra.Compliance package.

1.2.0 and earlier - 2026-03 to 2026-04 ​

Initial public releases established the core library and ecosystem:

  • AES-256-GCM field-level encryption with a versioned wire format and embedded key version for rotation, plus GDPR crypto-shredding.
  • The attribute model ([PersonalData], [DataSubjectId], [DeepPersonalData], [SerializedPersonalData]) and Roslyn analyzers TAYRA001 to TAYRA006.
  • Key stores: In-Memory, SQLite, PostgreSQL, HashiCorp Vault, Azure Key Vault, AWS Parameter Store, and AWS Secrets Manager.
  • Integrations for Entity Framework Core, Marten, MongoDB, Serilog, System.Text.Json, and ASP.NET Core, plus the dotnet tayra CLI.