Skip to content

Wolverine

Tayra integrates with Wolverine by wrapping Wolverine's message serializer, so [PersonalData] fields are encrypted as messages are serialized and decrypted as they are deserialized. Because encryption happens at the serialization boundary, PII is held as ciphertext at rest in the durable outbox and inbox, on the wire, and in dead-letter storage, while handlers always receive plaintext. It also provides a built-in ErasePersonalDataCommand handler for GDPR erasure workflows.

Prerequisites

Tayra ships a package per Wolverine major (pick by your Wolverine version):

  • Tayra.Wolverine6 - WolverineFx 6.x, .NET 9+.
  • Tayra.Wolverine5 - WolverineFx 5.x, .NET 8+.

The framework major is in the package name; the version number is Tayra's own. See Installation.

Wolverine 6 requires a code-generation strategy (Tayra.Wolverine6 only)

Wolverine 6 removed the runtime Roslyn compiler from the core package (GH-2876). Tayra relies on Wolverine's generated pipeline, so a Tayra.Wolverine6 host must choose one (Wolverine 5 / Tayra.Wolverine5 still bundles the compiler and needs nothing):

  • Runtime compilation (simplest): add the WolverineFx.RuntimeCompilation package (MIT) - it auto-registers, or call opts.UseRuntimeCompilation() inside UseWolverine(...).
  • Static codegen (leanest, recommended for production): pre-generate code with dotnet run -- codegen write and set opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static - no Roslyn at runtime.

This is a Wolverine platform requirement, not specific to Tayra.

Install

Install the package that matches your Wolverine major:

shell
dotnet add package Tayra.Wolverine6
shell
dotnet add package Tayra.Wolverine5

Either package exposes the same Tayra.Wolverine namespace and API, so the code below is identical regardless of which you install.

Reference Sample

A complete runnable sample lives at samples/Tayra.Samples.Wolverine. It boots a real IHost against PostgreSQL (via Marten for the transactional outbox and document store) and RabbitMQ (for cross-process messaging), and exercises every Tayra option toggle.

shell
docker compose up -d                                   # start postgres + rabbitmq
dotnet run --project samples/Tayra.Samples.Wolverine

The same Bootstrap.CreateHostBuilder(...) that powers the sample also powers the integration test suite at tests/Tayra.Wolverine.Tests/EndToEnd/ - every snippet you see below is exercised against Testcontainers Postgres and RabbitMQ on every CI run.

Setup

The minimal wiring registers Tayra core services, the Tayra serializer, and (in production) RabbitMQ + Marten for transport and outbox:

cs
.UseWolverine(opts =>
{
    // Wire the selected transport (RabbitMQ by default). Tayra's middleware is
    // transport-agnostic, so the rest of this block is identical across transports.
    ConfigureTransport(opts, options);

    // Persist failures to the Marten-backed dead-letter table so we can
    // assert what landed there after Tayra's redaction policies ran.
    opts.OnException<InvalidOperationException>().MoveToErrorQueue();

    // Wire every endpoint into the Marten-backed transactional outbox.
    opts.Policies.UseDurableInboxOnAllListeners();
    opts.Policies.UseDurableOutboxOnAllSendingEndpoints();

    foreach (var assembly in options.AdditionalDiscoveryAssemblies)
    {
        opts.Discovery.IncludeAssembly(assembly);
    }

    options.ConfigureWolverine?.Invoke(opts);

    opts.UseTayra(tayraOpts =>
    {
        tayraOpts.EncryptOutbound = options.EncryptOutbound;
        tayraOpts.DecryptInbound = options.DecryptInbound;
        tayraOpts.RedactExceptionMessages = options.RedactExceptionMessages;
    });
});
anchor

UseTayra() does four things:

  1. Wraps Wolverine's default serializer with TayraMessageSerializer so PII is encrypted at every serialization point
  2. Registers TayraWolverineOptions in DI for configuration
  3. Discovers the built-in ErasePersonalDataHandler in the Tayra.Wolverine assembly
  4. Registers a startup self-check that logs Tayra's protection scope (see What Tayra does not protect) and, on Wolverine 5 with exception-message redaction enabled, warns if a Wolverine upgrade breaks the reflection that redaction falls back to (Wolverine 6 has no such reflection - see below)

Prerequisites

Tayra core services must be registered via services.AddTayra() (optionally chaining a production key store) before UseTayra() is called. The serializer resolves ITayra from the container at startup.

Migrating from UseTayraMiddleware()

Earlier versions encrypted via handler middleware. That has been replaced by the serializer (which also protects the durable outbox/inbox, previously left in cleartext). Call UseTayra() instead; UseTayraMiddleware() remains as an [Obsolete] alias. The RedactDeadLetterQueues option was removed - dead-letter bodies are now encrypted by the serializer automatically.

Transports

Tayra's serializer runs at the serialization boundary, so it is transport-agnostic - it encrypts and decrypts message bodies the same way regardless of how the message travels. The integration suite exercises three transports against the same handlers:

TransportPackageCovered by
RabbitMQWolverineFx.RabbitMQThe runnable sample and most end-to-end tests
Local (in-memory)built inLocalTransportTests, SagaPiiTests
Azure Service BusWolverineFx.AzureServiceBusAzureServiceBusTransportTests (ASB emulator)

The sample's Bootstrap selects the transport via SampleHostOptions.Transport; UseTayra() is wired identically in every case.

Azure Service Bus emulator

The ASB tests run against the official Azure Service Bus emulator via Testcontainers (which also manages the emulator's required MSSQL sidecar). Because that is a heavyweight, slow-to-start dependency, the tests are gated behind the TAYRA_RUN_ASB_EMULATOR_TESTS environment variable - skipped in the default run, enabled in CI. The emulator cannot create entities at runtime, so the queues are predeclared in asb-emulator-config.json and Wolverine's AutoProvision/system queues are disabled for that host.

Marten + Wolverine Outbox

Pairing Wolverine with Marten gives you a transactional outbox: messages produced by handlers commit in the same DB transaction as your Marten document writes. Tayra's serializer wrapper layers field-level encryption on top, so PII is encrypted both in messages and in stored documents.

cs
services
    .AddMarten(opts =>
    {
        opts.Connection(options.PostgresConnectionString);
        opts.DatabaseSchemaName = options.SchemaName;
        opts.AutoCreateSchemaObjects = AutoCreate.All;
    })
    .UseLightweightSessions()
    // Wolverine outbox + inbox tables in the same Marten schema -
    // messages produced by handlers are persisted in the same DB
    // transaction as Marten document writes (transactional outbox).
    // This also provides durable saga storage for AccountReviewSaga.
    .IntegrateWithWolverine();
anchor

Options

PropertyTypeDefaultDescription
EncryptOutboundbooltrueEncrypt PII fields as messages are serialized (wire, outbox, inbox, dead-letter body)
DecryptInboundbooltrueDecrypt PII fields as messages are deserialized, before handlers run
RedactExceptionMessagesboolfalseReplace exception text with Exception details redacted by Tayra. before dead-letter persistence

Message bodies vs exception text

Message bodies in dead-letter storage are encrypted automatically by the serializer - there is no separate option for that. RedactExceptionMessages covers a different surface: the exception text, which Wolverine captures separately (handlers sometimes interpolate PII into exception messages) and which the serializer cannot reach. Exception-text redaction of broker-native dead letters is transport-dependent.

How exception-text redaction is wired

On Wolverine 6 (Tayra.Wolverine6), redaction runs on Wolverine's supported IDeadLetterInterceptor hook (added in WolverineFx 6.6.0). Tayra registers an interceptor that, just before the envelope is written to durable dead-letter storage, replaces the persisted exception with a redacted message while keeping the original exception type so operators can still filter and triage by type. No Wolverine internals are touched.

On Wolverine 5 (Tayra.Wolverine5), no such hook exists, so Tayra falls back to a failure-policy continuation that overwrites the exception's private message field via reflection. That is the surface the startup self-check and version-drift guard watch.

What Tayra does not protect

Tayra's Wolverine serializer encrypts and decrypts PII in the message body only. It does not touch Wolverine envelope metadata. Keep personal data out of:

  • Headers - Envelope.Headers and DeliveryOptions.Headers are sent in cleartext and are frequently logged by transports and traced by OpenTelemetry.
  • MessageId, CorrelationId, ConversationId, and the saga identity - routing/correlation identifiers persisted and traced in cleartext.
  • Scheduled-message metadata, routing keys, queue and topic names.
  • Saga state - Wolverine persists sagas through its own saga storage, which bypasses the Tayra serializer, so PII on a saga (including PII used as the saga identity) is stored in cleartext. The TAYRA009 analyzer flags this at build time. See Sagas.

At startup the integration logs this scope once (Information level), so the boundary is visible in your logs. If you need to correlate on a sensitive value, store an opaque id - a GUID or a Tayra blind index - in the header or saga identity and keep the personal data in the encrypted message body.

Headers are never encrypted

The serializer encrypts the message body only; it does not touch headers or correlation ids. Treat headers as permanently cleartext.

Messages

Annotate your Wolverine message classes with [DataSubjectId] and [PersonalData]. [DeepPersonalData] recursively encrypts nested objects:

cs
/// <summary>
/// Command to create a customer. PII fields are encrypted automatically by
/// Tayra middleware as the message moves through the Wolverine pipeline.
/// </summary>
public record CreateCustomerCommand(
    [property: DataSubjectId] string CustomerId,
    [property: PersonalData] string Name,
    [property: PersonalData(MaskValue = "redacted@example.com")] string Email,
    string AccountType);

/// <summary>
/// Cascading event emitted by the create handler. The middleware re-encrypts
/// PII on this outbound message before it leaves the bus.
/// </summary>
public record CustomerCreatedEvent(
    [property: DataSubjectId] string CustomerId,
    [property: PersonalData] string Name,
    [property: PersonalData(MaskValue = "redacted@example.com")] string Email,
    DateTimeOffset CreatedAt);

/// <summary>
/// Update command that uses <see cref="DeepPersonalDataAttribute"/> to encrypt
/// nested PII recursively without listing each property.
/// </summary>
public record UpdateCustomerProfileCommand(
    [property: DataSubjectId] string CustomerId,
    [property: DeepPersonalData] CustomerProfile Profile);

public record CustomerProfile(
    [property: PersonalData] string FullName,
    [property: PersonalData] string PhoneNumber,
    [property: DeepPersonalData] CustomerAddress Address);

public record CustomerAddress(
    [property: PersonalData] string Street,
    [property: PersonalData] string City,
    string Country);

/// <summary>
/// Command intentionally designed to make its handler throw - used to exercise
/// the dead-letter redaction policies.
/// </summary>
public record IntentionalFailureCommand(
    [property: DataSubjectId] string CustomerId,
    [property: PersonalData] string SensitiveNote);

/// <summary>
/// A non-PII message used to verify the middleware is a no-op when no PII
/// attributes are present.
/// </summary>
public record HousekeepingPing(string Reason, DateTimeOffset At);
anchor

Handlers

Wolverine handlers receive plaintext PII because the serializer decrypts on deserialize, before they run. They produce events containing plaintext PII and the serializer encrypts on the way out:

cs
/// <summary>
/// Wolverine discovers static handler classes by convention. The middleware
/// has already decrypted inbound PII before <c>Handle</c> runs, so business
/// logic operates on plaintext values.
/// </summary>
public static class CreateCustomerHandler
{
    public static CustomerCreatedEvent Handle(CreateCustomerCommand cmd, ILogger<CreateCustomerCommand> logger)
    {
        logger.LogInformation("Creating customer {CustomerId} ({Name})", cmd.CustomerId, cmd.Name);
        return new CustomerCreatedEvent(cmd.CustomerId, cmd.Name, cmd.Email, DateTimeOffset.UtcNow);
    }
}

/// <summary>
/// Subscriber that reacts to the cascading event. Tayra re-decrypts the PII
/// before this handler sees the event, even though the event was encrypted on
/// the wire.
/// </summary>
public static class CustomerCreatedAuditHandler
{
    public static void Handle(CustomerCreatedEvent evt, ILogger<CustomerCreatedEvent> logger)
    {
        logger.LogInformation("Audit: customer {CustomerId} created at {At}", evt.CustomerId, evt.CreatedAt);
    }
}

public static class UpdateCustomerProfileHandler
{
    public static void Handle(UpdateCustomerProfileCommand cmd, ILogger<UpdateCustomerProfileCommand> logger)
    {
        logger.LogInformation(
            "Profile update for {CustomerId}: {Name} in {City}",
            cmd.CustomerId, cmd.Profile.FullName, cmd.Profile.Address.City);
    }
}

public static class IntentionalFailureHandler
{
    public static Task Handle(IntentionalFailureCommand cmd)
    {
        // Exercise the failure-redaction code path. The exception message
        // includes plaintext PII so we can assert it is redacted by Tayra
        // before the envelope reaches the dead-letter queue.
        throw new InvalidOperationException(
            $"Simulated downstream failure for {cmd.CustomerId} (note='{cmd.SensitiveNote}')");
    }
}

public static class HousekeepingPingHandler
{
    public static void Handle(HousekeepingPing ping, ILogger<HousekeepingPing> logger)
    {
        logger.LogInformation("Housekeeping ping at {At} reason={Reason}", ping.At, ping.Reason);
    }
}

/// <summary>
/// Optional subscriber to <see cref="PersonalDataErasedEvent"/>, which is
/// returned by Tayra's built-in <c>ErasePersonalDataHandler</c>. Useful for
/// audit trails, downstream notifications, or cascading cleanup.
/// </summary>
public static class PersonalDataErasedAuditHandler
{
    public static void Handle(PersonalDataErasedEvent evt, ILogger<PersonalDataErasedEvent> logger)
    {
        logger.LogInformation(
            "GDPR erasure complete for {Subject} at {At} (reason={Reason})",
            evt.DataSubjectId, evt.ErasedAt, evt.Reason);
    }
}
anchor

How encryption works

TayraMessageSerializer wraps Wolverine's default serializer:

  • On write (serialize) - [PersonalData] fields are encrypted, then the message is serialized, so every serialization point (the wire, the durable outbox and inbox, dead-letter storage) holds ciphertext. The in-memory message instance is restored to plaintext afterward.
  • On read (deserialize) - the message is deserialized and its [PersonalData] fields are decrypted, so handlers receive cleartext.

Because field-level encryption leaves the payload as valid JSON, the wrapper keeps the inner serializer's content type and is transparent to Wolverine's content-type dispatch.

Fail-closed

If encryption or decryption fails (e.g., a key store is temporarily unavailable), the serializer surfaces the error rather than letting a message through unprotected, so plaintext PII is never sent or stored by accident.

Sagas

Wolverine sagas need care because saga state is persisted by Wolverine's own saga storage, not through the message serializer - the Tayra serializer only sees messages, never saga state. The recommended pattern is to keep PII out of saga state entirely: store an opaque correlation id and non-PII status on the saga, and let the personal data ride in the encrypted messages the saga consumes and produces. The serializer decrypts inbound PII before saga handlers run and re-encrypts it on the outbound messages.

The sample's AccountReviewSaga follows this pattern - its messages carry the PII while the saga keeps only the review id and a status:

cs
/// <summary>
/// Starts an account-review saga. The PII (<see cref="ApplicantNote"/>) travels in the
/// message - the Tayra middleware decrypts it before the saga handler runs - while
/// <see cref="AccountReviewId"/> is an opaque correlation key and <see cref="CustomerId"/> is
/// the data-subject id. Wolverine resolves the saga identity from <c>AccountReviewId</c>
/// (the "{SagaName}Id" convention with "Saga" stripped).
/// </summary>
public record StartAccountReview(
    string AccountReviewId,
    [property: DataSubjectId] string CustomerId,
    [property: PersonalData] string ApplicantNote);

/// <summary>Advances the saga with a reviewer decision. Carries PII to prove inbound decryption on a follow-up saga message.</summary>
public record SubmitReviewDecision(
    string AccountReviewId,
    [property: DataSubjectId] string CustomerId,
    bool Approved,
    [property: PersonalData] string ReviewerComment);

/// <summary>Cascading event emitted when the saga completes; PII is re-encrypted on the way out and decrypted for subscribers.</summary>
public record AccountReviewCompleted(
    string AccountReviewId,
    [property: DataSubjectId] string CustomerId,
    bool Approved,
    [property: PersonalData] string ReviewerComment);
anchor
cs
/// <summary>
/// A Wolverine saga that keeps NO personal data in its persisted state - only the opaque
/// review id and a status. PII is never stored on the saga (Wolverine persists saga state
/// through its own saga storage, which bypasses the Tayra serializer - see analyzer TAYRA010);
/// instead it flows through the encrypted messages the saga consumes and produces.
/// </summary>
public class AccountReviewSaga : Saga
{
    public string Id { get; set; } = default!;

    /// <summary>Non-PII saga state. Safe to persist in cleartext.</summary>
    public string Status { get; set; } = "Pending";

    public static AccountReviewSaga Start(StartAccountReview command, ILogger<AccountReviewSaga> logger)
    {
        // command.ApplicantNote has already been decrypted by the Tayra middleware.
        logger.LogInformation(
            "Account review {ReviewId} started for subject {CustomerId}", command.AccountReviewId, command.CustomerId);

        return new AccountReviewSaga
        {
            Id = command.AccountReviewId,
            Status = "UnderReview",
        };
    }

    public AccountReviewCompleted Handle(SubmitReviewDecision command, ILogger<AccountReviewSaga> logger)
    {
        // command.ReviewerComment is decrypted on the way in; the returned event is re-encrypted on the way out.
        Status = command.Approved ? "Approved" : "Rejected";
        logger.LogInformation("Account review {ReviewId} resolved as {Status}", command.AccountReviewId, Status);

        MarkCompleted();

        return new AccountReviewCompleted(
            command.AccountReviewId, command.CustomerId, command.Approved, command.ReviewerComment);
    }
}

/// <summary>Subscriber to the cascading completion event. Receives decrypted PII even though the event was encrypted on the wire.</summary>
public static class AccountReviewCompletedAuditHandler
{
    public static void Handle(AccountReviewCompleted evt, ILogger<AccountReviewCompleted> logger)
    {
        logger.LogInformation(
            "Audit: account review {ReviewId} completed (approved={Approved})", evt.AccountReviewId, evt.Approved);
    }
}
anchor

PII on saga state is stored in cleartext

If you put [PersonalData] on a saga type, it is written to saga storage unencrypted. The TAYRA009 analyzer flags this at build time - see What Tayra does not protect and the analyzer reference.

GDPR Erasure Command

Tayra includes a built-in command and handler for GDPR Article 17 "right to erasure" workflows. Send it through the Wolverine bus:

cs
// 5. GDPR Article 17 - invoke the built-in erasure command.
await bus.InvokeAsync(new ErasePersonalDataCommand(
    DataSubjectId: "cust-101",
    Reason: "GDPR Article 17 request"));
anchor

The built-in ErasePersonalDataHandler:

  1. Calls cryptoEngine.DeleteAllKeysAsync(dataSubjectId) to delete all encryption keys
  2. Logs the erasure with the data subject ID and reason
  3. Returns a PersonalDataErasedEvent for downstream handlers

The cascading event is returned by the built-in handler and can be consumed by your own handlers for audit logging, notifications, or cascading operations. See PersonalDataErasedAuditHandler in the sample for a typical subscriber.

Test Coverage

The integration suite under tests/Tayra.Wolverine.Tests/EndToEnd/ covers, against a real Postgres + RabbitMQ (and, in CI, the Azure Service Bus emulator):

  • Basic messaging - InvokeAsync, PublishAsync, cascading events, non-PII pass-through
  • Configuration toggles - every combination of EncryptOutbound / DecryptInbound
  • Erasure flow - Article 17 end-to-end including key deletion verification
  • Dead letters - body encrypted at rest in wolverine_dead_letters (raw-bytes inspection) and exception-message redaction (positive and negative controls)
  • Outbox at rest - raw wolverine_outgoing_envelopes bytes inspected to prove cascaded PII is ciphertext, not just that values round-trip
  • Deep PII - nested objects via [DeepPersonalData] round-trip across the bus
  • Marten persistence - encryption-at-rest verification by inspecting raw JSONB, plus Wolverine outbox + RabbitMQ delivery
  • Transports - the same handlers over the local in-memory transport and Azure Service Bus, not just RabbitMQ
  • Sagas - a multi-step saga that round-trips PII through encrypted messages while keeping saga state PII-free
  • Version-drift guard (Wolverine 5 only) - fast in-process tests that fail loudly if a WolverineFx upgrade breaks the reflection the Wolverine 5 redaction fallback depends on (no containers needed). Wolverine 6 redacts through the supported IDeadLetterInterceptor hook, so there is nothing to guard

Run them locally with Docker available:

shell
docker compose up -d
dotnet test tests/Tayra.Wolverine.Tests --filter "Category=Integration"

See Also