Skip to content

Roslyn Analyzers

Tayra ships with Roslyn analyzers that catch common PII attribute misconfigurations at compile time. These run in your IDE and during dotnet build, providing immediate feedback when entity models are configured incorrectly.

Auto-Installation

The analyzers are bundled with the Tayra.Core NuGet package. When you reference Tayra.Core, the analyzers are automatically loaded by your IDE (Visual Studio, Rider, VS Code with C# Dev Kit) and the build system. No additional package installation is required.

Properties and fields

The Tayra attributes apply to properties and public instance fields alike, and the analyzers flag both. Wherever a rule below talks about a "property", the same diagnostic fires for an annotated public instance field.

Analyzer Rules

TAYRA001: Missing [DataSubjectId]

PropertyValue
IDTAYRA001
SeverityWarning
CategoryTayra.Usage

Trigger: A class or struct has properties marked with [PersonalData], but no property is marked with [DataSubjectId].

Why it matters: Tayra derives encryption keys from the data subject identifier. Without a [DataSubjectId], the field encrypter cannot determine which key to use, and EncryptAsync fails closed with an InvalidOperationException at runtime. The analyzer catches this at compile time instead.

Example (triggers TAYRA001):

csharp
// Warning TAYRA001: Type 'Customer' has [PersonalData] fields
// but no [DataSubjectId] property
public class Customer
{
    public Guid Id { get; set; }         // Missing [DataSubjectId]

    [PersonalData]
    public string Name { get; set; }
}

Fix:

csharp
public class Customer
{
    [DataSubjectId]                       // Added
    public Guid Id { get; set; }

    [PersonalData]
    public string Name { get; set; }
}

TAYRA002: Unused [DataSubjectId]

PropertyValue
IDTAYRA002
SeverityInfo
CategoryTayra.Usage

Trigger: A class or struct has a property marked with [DataSubjectId], but no properties are marked with [PersonalData].

Why it matters: A [DataSubjectId] without any PII fields to encrypt has no effect. This usually indicates a missing [PersonalData] attribute or a leftover [DataSubjectId] from a refactoring.

Example (triggers TAYRA002):

csharp
// Info TAYRA002: Type 'AuditLog' has [DataSubjectId]
// but no [PersonalData] fields
public class AuditLog
{
    [DataSubjectId]
    public Guid UserId { get; set; }

    public string Action { get; set; }   // Not marked [PersonalData]
    public DateTime Timestamp { get; set; }
}

Fix: Either add [PersonalData] to fields that contain personal data, or remove the unused [DataSubjectId].


TAYRA003: [DeepPersonalData] on Non-Class Type

PropertyValue
IDTAYRA003
SeverityError
CategoryTayra.Usage

Trigger: [DeepPersonalData] is applied to a property whose type is not a class or record (e.g., string, int, DateTime, a struct, or an enum).

Why it matters: [DeepPersonalData] tells Tayra to recursively process a nested object for PII fields. This only makes sense for class or record types that can contain their own [PersonalData] properties. Applying it to a primitive or value type is always a mistake.

Example (triggers TAYRA003):

csharp
public class Order
{
    [DataSubjectId]
    public Guid CustomerId { get; set; }

    // Error TAYRA003: [DeepPersonalData] on property 'Total' is invalid.
    // It must be applied to a class or record type, not 'decimal'.
    [DeepPersonalData]
    public decimal Total { get; set; }
}

Fix: Use [PersonalData] for the member (a string encrypts in place; a non-string value type serializes into a companion byte[]?). Use [DeepPersonalData] only on members whose type is a class containing its own PII annotations:

csharp
public class Order
{
    [DataSubjectId]
    public Guid CustomerId { get; set; }

    [DeepPersonalData]
    public ShippingAddress Address { get; set; }  // Class with [PersonalData] fields
}

public class ShippingAddress
{
    [PersonalData]
    public string Street { get; set; }

    [PersonalData]
    public string City { get; set; }
}

TAYRA004: Multiple [DataSubjectId] Without Group

PropertyValue
IDTAYRA004
SeverityWarning
CategoryTayra.Usage

Trigger: A class has two or more [DataSubjectId] properties, and at least one of them does not specify a Group.

Why it matters: When multiple data subject identifiers exist on the same type, Tayra needs to know which [PersonalData] fields belong to which subject. Without Group, the key derivation is ambiguous. Each [DataSubjectId] and its corresponding [PersonalData] fields must be linked via a shared Group name.

Example (triggers TAYRA004):

csharp
// Warning TAYRA004: Type 'Transfer' has multiple [DataSubjectId]
// properties without Group specified
public class Transfer
{
    [DataSubjectId]                          // No Group
    public Guid SenderId { get; set; }

    [DataSubjectId]                          // No Group
    public Guid ReceiverId { get; set; }

    [PersonalData]
    public string SenderName { get; set; }

    [PersonalData]
    public string ReceiverName { get; set; }
}

Fix: Assign a Group to each [DataSubjectId] and its corresponding [PersonalData] fields:

csharp
public class Transfer
{
    [DataSubjectId(Group = "sender")]
    public Guid SenderId { get; set; }

    [DataSubjectId(Group = "receiver")]
    public Guid ReceiverId { get; set; }

    [PersonalData(Group = "sender")]
    public string SenderName { get; set; }

    [PersonalData(Group = "receiver")]
    public string ReceiverName { get; set; }
}

TAYRA005: Missing Companion Property for [BlindIndex]

PropertyValue
IDTAYRA005
SeverityWarning
CategoryTayra.Usage

Trigger: A property has [BlindIndex] but the expected companion property (default: {PropertyName}Index) does not exist on the type.

Why it matters: Blind indexes compute an HMAC hash and store it in a companion property. Without the companion, the hash has nowhere to go and queries cannot work.

Example (triggers TAYRA005):

csharp
// Warning TAYRA005: Property 'Email' has [BlindIndex] but companion
// property 'EmailIndex' was not found on type 'Customer'
public class Customer
{
    [DataSubjectId]
    public Guid Id { get; set; }

    [PersonalData, BlindIndex]
    public string Email { get; set; }
    // Missing: public string? EmailIndex { get; set; }
}

Fix: Add the companion property, or set IndexPropertyName to point to an existing one:

csharp
public class Customer
{
    [DataSubjectId]
    public Guid Id { get; set; }

    [PersonalData, BlindIndex]
    public string Email { get; set; }
    public string? EmailIndex { get; set; }  // Added
}

TAYRA006: [BlindIndex] on a Low-Cardinality Member

PropertyValue
IDTAYRA006
SeverityWarning
CategoryTayra.Security

Trigger: [BlindIndex] is applied to a bool, or to an enum with 8 or fewer members.

Why it matters: A blind index maps each distinct plaintext to one fixed hash. That is safe for a high-entropy value like an email address or a national ID, where an attacker who can read the column learns very little. Over a bool there are exactly two distinct hashes, so an attacker counts how often each appears and labels them from known population distributions. The "encrypted" field is then a plaintext column with extra steps.

Example (triggers TAYRA006):

csharp
// Warning TAYRA006: Member 'HivPositive' has [BlindIndex] but its type 'bool'
// has very few possible values.
public class Patient
{
    [DataSubjectId]
    public Guid Id { get; set; }

    [PersonalData, BlindIndex]
    public bool HivPositive { get; set; }
    public byte[]? HivPositiveEncrypted { get; set; }
    public string? HivPositiveIndex { get; set; }
}

Fix: Filter on a non-PII discriminator instead, or accept that the member is effectively public and do not encrypt it at all. See Frequency Analysis for the truncation lever, which trades false positives for reduced leakage.

This rule changed in 2.4.0

It previously reported [BlindIndex] on any non-string member as unsupported. Blind indexes now work on any type Tayra can canonicalize (int, DateOnly, Guid, enums, ...), so the type gate is gone and the rule was retargeted at the hazard that replaced it. A [BlindIndex] on an int BSN is now correct and silent.


TAYRA007: [PersonalData] Member Mapped Into a Flat-Table Column

PropertyValue
IDTAYRA007
SeverityWarning
CategoryTayra.Usage

Trigger: A [PersonalData] member is mapped into a Marten FlatTableProjection column via StatementMap<T>.Map, Increment, or Decrement.

Why it matters: Flat-table columns are written via raw SQL and never pass through the Tayra serializer, so the value is stored in cleartext. Unlike binary events, flat tables have no encryption seam - this cannot be fixed at runtime, only avoided by design.

Example (triggers TAYRA007):

csharp
flat.Project<CustomerRegistered>(map =>
{
    map.Map(x => x.CustomerId);
    // Warning TAYRA007: 'Email' is [PersonalData] but is mapped into a
    // flat-table column, which is written in cleartext.
    map.Map(x => x.Email);
});

Fix: Map a blind-index companion instead of the plaintext member, so the column holds a one-way HMAC for equality search rather than the value:

csharp
flat.Project<CustomerRegistered>(map =>
{
    map.Map(x => x.CustomerId);
    map.Map(x => x.EmailIndex);   // HMAC companion, not x.Email
});

Keep the actual personal data in the encrypted JSONB document projection and look it up by id when you need the value. See Marten integration → Flat-table projections.


TAYRA008: [PersonalData] Member Configured as a Duplicated Field

PropertyValue
IDTAYRA008
SeverityWarning
CategoryTayra.Usage

Trigger: A [PersonalData] member is configured as a Marten duplicated field - via the [DuplicateField] attribute or a Schema.For<T>().Duplicate(x => x.Member) call.

Why it matters: Marten populates a duplicated column by reading the value off the .NET object, outside the Tayra serializer. Because Tayra encrypts the field in place during serialization, the column ends up holding non-deterministic ciphertext - unqueryable (the purpose of duplication) and not decryptable on read.

Example (triggers TAYRA008):

csharp
public class Customer
{
    [DataSubjectId] public string Id { get; set; }

    // Warning TAYRA008: 'Email' is [PersonalData] but is configured as a
    // Marten duplicated field.
    [PersonalData, DuplicateField]
    public string Email { get; set; }
}

// also flagged:
opts.Schema.For<Customer>().Duplicate(x => x.Email);

Fix: Duplicate the blind-index companion instead - it is a deterministic HMAC that is safe to store and actually queryable:

csharp
opts.Schema.For<Customer>().Duplicate(x => x.EmailIndex);   // companion, not x.Email

For an [ArrayBlindIndex] companion the same safety rule applies (duplicate the hash companion, never the [PersonalData] source), but a duplicated array column does not speed up Contains lookups in Marten - prefer the JSONB GIN index. See Array blind indexes - duplicated fields.

See Marten integration -> Duplicated fields.


TAYRA009: [PersonalData] Member on a Wolverine Saga

PropertyValue
IDTAYRA009
SeverityWarning
CategoryTayra.Usage

Trigger: A type deriving from Wolverine.Saga has a [PersonalData] or [DeepPersonalData] member.

Why it matters: Wolverine persists and correlates saga state through its own saga storage - the saga's identity is the storage key and the state is written outside the Tayra serializer. Tayra's Wolverine middleware only encrypts message bodies, not saga state, so personal data on a saga (including any PII used as the saga identity) is stored in cleartext. Like flat-table columns, saga storage has no Tayra encryption seam.

Example (triggers TAYRA009):

csharp
public class AccountReviewSaga : Wolverine.Saga
{
    public string Id { get; set; }

    // Warning TAYRA009: 'AccountReviewSaga' derives from Wolverine.Saga and
    // has [PersonalData] members. Saga storage writes this in cleartext.
    [PersonalData]
    public string ApplicantEmail { get; set; }
}

Fix: Keep only an opaque correlation id and non-PII status on the saga; carry the personal data in the encrypted messages the saga consumes and produces. The middleware decrypts inbound PII before saga handlers run and re-encrypts it on outbound messages.

csharp
public class AccountReviewSaga : Wolverine.Saga
{
    public string Id { get; set; }            // opaque review id
    public string Status { get; set; }        // non-PII state

    // PII arrives decrypted in the message and is re-encrypted on the way out -
    // it never lands in saga storage.
    public AccountReviewCompleted Handle(SubmitReviewDecision command) { /* ... */ }
}

See Wolverine integration → What Tayra does not protect.


TAYRA010: [ArrayBlindIndex] Companion Shape Mismatch

PropertyValue
IDTAYRA010
SeverityWarning
CategoryTayra.Usage

Trigger: One of the following is true for a property annotated with [ArrayBlindIndex]:

  • The source property type is not a supported string collection (allowed: string[], List<string>, IList<string>, IReadOnlyList<string>, HashSet<string>, and their nullable-element variants). IEnumerable<string> and non-string element types are rejected.
  • The companion property has a different collection kind than the source (for example string[] source with a List<string> companion).
  • The companion property has a different element nullability than the source (for example string[] source with a string?[] companion, or vice versa).
  • The companion property is missing entirely.
  • Both [BlindIndex] and [ArrayBlindIndex] are applied to the same property.

Why it matters: Array blind indexes write one HMAC per element into a companion collection of the same shape and length as the source. If the companion kind or element nullability does not match, the indexer cannot align hashes to elements, and the runtime guard throws. Catching it at compile time keeps you from shipping a broken model.

Example (triggers TAYRA010):

csharp
public class User
{
    [DataSubjectId]
    public Guid Id { get; set; }

    // Warning TAYRA010: 'Emails' has [ArrayBlindIndex] but the companion
    // 'EmailsIndex' is List<string> while the source is string[].
    // The companion must be string[] to match the source shape.
    [PersonalData, ArrayBlindIndex]
    public string[] Emails { get; set; } = [];

    public List<string> EmailsIndex { get; set; } = [];   // wrong kind
}

Fix: Declare the companion with the same collection kind and element nullability as the source:

csharp
public class User
{
    [DataSubjectId]
    public Guid Id { get; set; }

    [PersonalData, ArrayBlindIndex]
    public string[] Emails { get; set; } = [];

    public string[] EmailsIndex { get; set; } = [];   // matches source shape
}

See Array Blind Indexes for the full shape-rules table.


TAYRA011: [BinaryEvent] Type Has [PersonalData] Fields Tayra.Marten9

PropertyValue
IDTAYRA011
SeverityWarning
CategoryTayra.Usage

Trigger: A type marked with Marten's [BinaryEvent] also has [PersonalData] fields.

Why it matters: Marten's binary event path serializes to mt_events.bdata through a separate IEventBinarySerializer, bypassing the Tayra document serializer. Unless that binary serializer is wrapped with TayraBinaryEventSerializer, the personal data is stored in cleartext.

Example (triggers TAYRA011):

csharp
// Warning TAYRA011: Type 'CustomerRegistered' is a [BinaryEvent] with
// [PersonalData] fields. Marten's binary serialization bypasses the Tayra
// serializer; wrap the binary serializer with TayraBinaryEventSerializer.
[BinaryEvent]
public class CustomerRegistered
{
    [DataSubjectId]
    public string CustomerId { get; set; }

    [PersonalData]
    public string Name { get; set; }
}

Fix: Wrap the binary serializer with TayraBinaryEventSerializer (set opts.Events.DefaultBinarySerializer before UseTayra() for automatic wrapping, or pass it to opts.Events.UseBinarySerializer<T>()). See Marten integration → Binary events. If the event genuinely holds no personal data, remove the [PersonalData] annotation.


TAYRA013: Missing Companion Member for a Non-String [PersonalData]

PropertyValue
IDTAYRA013
SeverityError
CategoryTayra.Usage

Trigger: A non-string member has [PersonalData] (for example an int, DateOnly, Guid, or an enum) but the companion member that holds the ciphertext (default: {MemberName}Encrypted, or the name given by EncryptedFieldName) does not exist on the type.

Why it matters: A value type cannot hold ciphertext in place - an int has nowhere to put a Base64 string - so [PersonalData] on a non-string member writes the ciphertext to a companion byte[]? member instead. Without the companion there is nowhere for it to go. Before 2.4.0 Tayra logged a warning and skipped the field, which silently persisted the value as plaintext. It now fails closed at runtime, and this analyzer catches it at compile time.

Example (triggers TAYRA013):

csharp
public class Patient
{
    [DataSubjectId]
    public string PatientId { get; set; }

    // Error TAYRA013: Member 'Bsn' has [PersonalData] but companion
    // member 'BsnEncrypted' was not found on type 'Patient'.
    [PersonalData]
    public int Bsn { get; set; }
}

A typo is enough to trigger it, which is the point - BsnEncryped is not BsnEncrypted, and before this rule that typo shipped PII in the clear.

Fix: Add the companion, or point EncryptedFieldName at an existing member:

csharp
public class Patient
{
    [DataSubjectId]
    public string PatientId { get; set; }

    [PersonalData]
    public int Bsn { get; set; }
    public byte[]? BsnEncrypted { get; set; }   // Added
}

TAYRA014: Masking on a Non-String [PersonalData] Member

PropertyValue
IDTAYRA014
SeverityError
CategoryTayra.Usage

Trigger: [PersonalData] sets a Masking strategy (MaskAfter, MaskBefore, MaskEmailDomain, ...) on a member that is not a string.

Why it matters: Masking retains a partial cleartext view (e.g. Ja******, jane@***.***) that survives crypto-shredding. It is only meaningful over a string, and it is a deliberate partial-cleartext leak. There is no sensible masked form of an int or a DateOnly, so masking a non-string member is always a mistake. At runtime Tayra throws an InvalidOperationException at metadata-build time; this analyzer surfaces the same problem at compile time. Use ReplacementValue for non-string members - it leaks nothing.

Example (triggers TAYRA014):

csharp
public class Patient
{
    [DataSubjectId]
    public string PatientId { get; set; }

    // Error TAYRA014: Member 'Bsn' sets Masking but its type 'int' is not a string.
    // Masking is string-only; use ReplacementValue instead.
    [PersonalData(Masking = MaskingStrategies.MaskAfter, MaskingParameter = 2)]
    public int Bsn { get; set; }
    public byte[]? BsnEncrypted { get; set; }
}

Fix: Drop Masking and use ReplacementValue for the value written back after shredding:

csharp
[PersonalData(ReplacementValue = "-1")]
public int Bsn { get; set; }
public byte[]? BsnEncrypted { get; set; }

In the fluent API this is enforced at compile time: the WithMask* methods only exist on a string member, so masking a non-string member does not compile at all.


TAYRA015: Encrypted Member Compared as Cleartext in a Query

PropertyValue
IDTAYRA015
SeverityWarning
CategoryTayra.Usage

Trigger: A [PersonalData] or [BlindIndex] member is compared (==, !=, .Equals(...)) against a value inside a query predicate - a lambda that converts to Expression<Func<T, bool>>, or a LINQ query-syntax clause bound to a method taking one.

Why it matters: This is the failure mode with no symptom. At rest the member holds non-deterministic ciphertext (or, for a non-string member, a zeroed default with the real value in its companion), so the predicate is translated to SQL and run against the stored ciphertext. Nothing throws. The query returns zero rows, every time, which reads as "no such patient" rather than as a bug, and a != filter matches everything instead. Comparing against the default value can match every row.

Example (triggers TAYRA015):

csharp
// Warning TAYRA015: Member 'Bsn' is encrypted at rest, so this query comparison
// runs against the stored ciphertext and silently matches zero rows.
var patient = await session.Query<Patient>()
    .Where(x => x.Bsn == 123456789)
    .FirstOrDefaultAsync();

Fix: Compare the deterministic blind-index companion. BuildPredicateAsync builds the predicate so the query site never names the companion member or the index:

csharp
var match = await blindIndexer.BuildPredicateAsync((Patient x) => x.Bsn, 123456789);

var patient = await session.Query<Patient>()
    .Where(match)
    .FirstOrDefaultAsync();

If the member has no blind index yet, add [BlindIndex] and its companion first - see Blind Indexes. The diagnostic message tells you which of the two fixes applies.

What it does not report: a comparison in a lambda that converts to a plain Func<,> runs in memory over materialized objects, which Tayra has already decrypted, so it is correct and is deliberately left alone:

csharp
// No diagnostic: these objects are decrypted, this comparison is fine.
var adults = patients.Where(x => x.Bsn == 123456789).ToList();

Comparisons against null are also not reported: checking whether a field is populated reads correctly against ciphertext.

Equality only

A blind index is an HMAC, so it answers equality and nothing else. Contains, StartsWith and range comparisons on an encrypted member cannot be rewritten and are not reported by this rule. See Blind Index Security.


Suppressing Analyzers

If you need to suppress a specific analyzer rule, you have several options:

Inline Suppression

Use #pragma warning disable to suppress a rule for a specific block of code:

csharp
#pragma warning disable TAYRA001 // Intentionally no DataSubjectId
public class LegacyEntity
{
    [PersonalData]
    public string Name { get; set; }
}
#pragma warning restore TAYRA001

SuppressMessage Attribute

Use [SuppressMessage] for a cleaner approach:

csharp
using System.Diagnostics.CodeAnalysis;

[SuppressMessage("Tayra.Usage", "TAYRA001",
    Justification = "Encryption handled externally")]
public class ExternalEntity
{
    [PersonalData]
    public string Name { get; set; }
}

.editorconfig Configuration

Suppress or change severity for an entire project using .editorconfig:

ini
# Disable TAYRA001 entirely
dotnet_diagnostic.TAYRA001.severity = none

# Downgrade TAYRA004 to a suggestion
dotnet_diagnostic.TAYRA004.severity = suggestion

# Upgrade TAYRA002 to a warning
dotnet_diagnostic.TAYRA002.severity = warning

NoWarn in .csproj

Suppress in the project file to affect the entire project:

xml
<PropertyGroup>
  <NoWarn>$(NoWarn);TAYRA002</NoWarn>
</PropertyGroup>

Summary Table

Rule IDSeverityDescription
TAYRA001WarningEntity with [PersonalData] must have [DataSubjectId]
TAYRA002Info[DataSubjectId] without [PersonalData] fields is unused
TAYRA003Error[DeepPersonalData] must be on a class or record type
TAYRA004WarningMultiple [DataSubjectId] properties require Group
TAYRA005Warning[BlindIndex] without companion property
TAYRA006Warning[BlindIndex] on a low-cardinality member (bool, small enum)
TAYRA007Warning[PersonalData] member mapped into a flat-table column
TAYRA008Warning[PersonalData] member configured as a duplicated field
TAYRA009Warning[PersonalData] member on a Wolverine saga
TAYRA010Warning[ArrayBlindIndex] companion shape mismatch or missing companion
TAYRA011Warning[BinaryEvent] type has [PersonalData] fields
TAYRA013ErrorNon-string [PersonalData] member has no companion byte[] to hold the ciphertext
TAYRA014ErrorMasking set on a non-string [PersonalData] member (masking is string-only)
TAYRA015WarningEncrypted member compared as cleartext in a query predicate (silently matches zero rows)

See Also