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]
| Property | Value |
|---|---|
| ID | TAYRA001 |
| Severity | Warning |
| Category | Tayra.Usage |
Trigger: A class or struct has properties marked with [PersonalData] or [SerializedPersonalData], 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):
// 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:
public class Customer
{
[DataSubjectId] // Added
public Guid Id { get; set; }
[PersonalData]
public string Name { get; set; }
}TAYRA002: Unused [DataSubjectId]
| Property | Value |
|---|---|
| ID | TAYRA002 |
| Severity | Info |
| Category | Tayra.Usage |
Trigger: A class or struct has a property marked with [DataSubjectId], but no properties are marked with [PersonalData] or [SerializedPersonalData].
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):
// 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
| Property | Value |
|---|---|
| ID | TAYRA003 |
| Severity | Error |
| Category | Tayra.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):
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 string members or [SerializedPersonalData] for non-string value types. Use [DeepPersonalData] only on members whose type is a class containing its own PII annotations:
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
| Property | Value |
|---|---|
| ID | TAYRA004 |
| Severity | Warning |
| Category | Tayra.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):
// 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:
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]
| Property | Value |
|---|---|
| ID | TAYRA005 |
| Severity | Warning |
| Category | Tayra.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):
// 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:
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 Non-String Property
| Property | Value |
|---|---|
| ID | TAYRA006 |
| Severity | Warning |
| Category | Tayra.Usage |
Trigger: [BlindIndex] is applied to a property whose type is not string.
Why it matters: Blind indexes use text normalization transforms (lowercase, trim, etc.) and HMAC-SHA256 hashing, which only work on string values.
Example (triggers TAYRA006):
// Warning TAYRA006: Property 'Age' has [BlindIndex] but its type is 'int'.
// Blind indexes only support string properties.
public class Customer
{
[DataSubjectId]
public Guid Id { get; set; }
[PersonalData, BlindIndex]
public int Age { get; set; }
}Fix: Only use [BlindIndex] on string properties. For non-string fields, convert to string before storing if you need searchability.
TAYRA007: [PersonalData] Member Mapped Into a Flat-Table Column
| Property | Value |
|---|---|
| ID | TAYRA007 |
| Severity | Warning |
| Category | Tayra.Usage |
Trigger: A [PersonalData] (or [SerializedPersonalData]) 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):
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:
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
| Property | Value |
|---|---|
| ID | TAYRA008 |
| Severity | Warning |
| Category | Tayra.Usage |
Trigger: A [PersonalData] (or [SerializedPersonalData]) 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):
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:
opts.Schema.For<Customer>().Duplicate(x => x.EmailIndex); // companion, not x.EmailFor 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
| Property | Value |
|---|---|
| ID | TAYRA009 |
| Severity | Warning |
| Category | Tayra.Usage |
Trigger: A type deriving from Wolverine.Saga has a [PersonalData], [SerializedPersonalData], 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):
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.
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
| Property | Value |
|---|---|
| ID | TAYRA010 |
| Severity | Warning |
| Category | Tayra.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 aList<string>companion). - The companion property has a different element nullability than the source (for example
string[]source with astring?[]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):
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:
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
| Property | Value |
|---|---|
| ID | TAYRA011 |
| Severity | Warning |
| Category | Tayra.Usage |
Trigger: A type marked with Marten's [BinaryEvent] also has [PersonalData] (or [SerializedPersonalData]) 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):
// 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.
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:
#pragma warning disable TAYRA001 // Intentionally no DataSubjectId
public class LegacyEntity
{
[PersonalData]
public string Name { get; set; }
}
#pragma warning restore TAYRA001SuppressMessage Attribute
Use [SuppressMessage] for a cleaner approach:
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:
# 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 = warningNoWarn in .csproj
Suppress in the project file to affect the entire project:
<PropertyGroup>
<NoWarn>$(NoWarn);TAYRA002</NoWarn>
</PropertyGroup>Summary Table
| Rule ID | Severity | Description |
|---|---|---|
| TAYRA001 | Warning | Entity with [PersonalData] must have [DataSubjectId] |
| TAYRA002 | Info | [DataSubjectId] without [PersonalData] fields is unused |
| TAYRA003 | Error | [DeepPersonalData] must be on a class or record type |
| TAYRA004 | Warning | Multiple [DataSubjectId] properties require Group |
| TAYRA005 | Warning | [BlindIndex] without companion property |
| TAYRA006 | Warning | [BlindIndex] on a non-string property |
| TAYRA007 | Warning | [PersonalData] member mapped into a flat-table column |
| TAYRA008 | Warning | [PersonalData] member configured as a duplicated field |
| TAYRA009 | Warning | [PersonalData] member on a Wolverine saga |
| TAYRA010 | Warning | [ArrayBlindIndex] companion shape mismatch or missing companion |
| TAYRA011 | Warning | [BinaryEvent] type has [PersonalData] fields |
See Also
- Getting Started - Core attribute usage
- Configuration - Service registration
- Collection Encryption - Encrypting list and array properties
- Blind Indexes - Searchable encryption with HMAC blind indexes
- Array Blind Indexes - Collection blind indexes validated by TAYRA010
