Skip to content

Marking data as PII

Marking a value [PII] tells Chronicle it holds personally identifiable information under GDPR — a C# attribute, a Kotlin/Java @Pii annotation, a TypeScript @pii() decorator, or an Elixir pii macro, depending on the client. When Chronicle sees the marker on an event property or a type, it encrypts the value automatically when the event is written to the event log and decrypts it transparently on read.

Personal data rarely sits in a flat list of properties. A date of birth arrives wrapped in a VerifiedDateOfBirth value object alongside who verified it; a diagnosis carries the condition and the clinician together. Chronicle follows the marker down through that structure: whether you mark a concept nested inside a value object, or the value object type itself, encryption lands on the individual values at the bottom. The document keeps its shape, each value stays independently encrypted, and the release on read mirrors the encryption on write.

using Cratis.Chronicle.Compliance.GDPR;

The marker, in each client:

// C#
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class PIIAttribute(string details = "") : Attribute
// Kotlin / Java
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Pii(val description: String = "")
// TypeScript
export function pii(details?: string): PropertyDecorator & ClassDecorator
// Elixir
defmacro pii(field, details \\ "")

The optional details parameter lets you record why the value is classified as PII — for example, the legal basis under which it is collected or the retention period. This information is stored in the event schema and can be used by compliance reporting tools.

using Cratis.Chronicle.Compliance.GDPR;
[PII("Collected under GDPR Art. 6(1)(b) — necessary for contract performance")]
public record PiiAttrPersonName(string Value) : ConceptAs<string>(Value);
TargetSupportedNotes
ConceptAs<T> classYesPreferred approach — marks the concept type itself as PII
Event propertyYesMarks a single property of an event as PII
Value object classYesMarks every value the type holds as PII, however deeply nested
Property typed as a value objectYesSame effect, scoped to that one property
Collection propertyYesThe collection is encrypted as a whole — see Collections are encrypted as a whole
Geospatial (Point, LineString, Polygon)NoNever classified inside, and marking one encrypts a value that cannot be materialized back after erasure — see Geospatial values are not looked inside
Polymorphic base type or dictionaryNoChronicle cannot classify values inside either and fails rather than store them unprotected
EventSourceId or EventSourceId<T>NoThrows PIINotSupportedOnEventSourceId at runtime

This table describes what the C# client validates. No other client performs the same checks yet: Kotlin/Java, TypeScript, and Elixir don’t verify that a [PII]-marked type actually extends the client’s ConceptAs<T> equivalent, and none of them throws an equivalent of PIINotSupportedOnEventSourceId when you mark an event-source identifier type — the marker is silently accepted rather than rejected. Treat both restrictions as rules to follow across every client, not ones every client enforces for you today.

You can mark a single property on an event record as PII. This is useful when the property type is a primitive and you cannot or do not want to introduce a dedicated concept type.

using Cratis.Chronicle.Compliance.GDPR;
using Cratis.Chronicle.Events;
[EventType]
public record PiiAttrEmployeeRegistered(
[PII] string FirstName,
[PII] string LastName,
string Department);

When this event is written, FirstName and LastName are encrypted. Department is stored as plaintext.

The preferred approach is to mark the ConceptAs<T> type itself as PII. Every property across every event that uses this type is then automatically encrypted — you declare the rule once and it applies everywhere.

using Cratis.Chronicle.Compliance.GDPR;
[PII]
public record PiiAttrConceptPersonName(string Value) : ConceptAs<string>(Value)
{
public static readonly PiiAttrConceptPersonName NotSet = new(string.Empty);
public static implicit operator string(PiiAttrConceptPersonName name) => name.Value;
public static implicit operator PiiAttrConceptPersonName(string value) => new(value);
}

Not every piece of personal data is a single value. When a whole value object is personal — a diagnosis, a passport, a home address — mark the type itself. Every value it holds is then treated as PII wherever that type appears, without annotating each member.

using Cratis.Chronicle.Compliance.GDPR;
// Every value this type holds is personal, so mark the type once.
[PII]
public record PiiAttrDiagnosis(string Condition, string DiagnosedBy);
// Both Condition and DiagnosedBy are encrypted wherever a PiiAttrDiagnosis appears.
public record PiiAttrPatientRecord(string Name, PiiAttrDiagnosis Diagnosis);

Chronicle pushes the marker down to the individual values rather than encrypting the object as one blob. In storage you still see a Diagnosis sub-document with a Condition and a DiagnosedBy field; both hold ciphertext. That matters for more than tidiness: the shape is what lets the read model materialize back into its value-object type, and it keeps each value separately encrypted rather than fused into a single opaque string.

The same happens when you mark a property whose type is a value object — the effect is identical, just scoped to that one property instead of to the type everywhere.

The marker does not have to sit at the top level. A ConceptAs<T> marked [PII] is found wherever it ends up — directly on an event, one level down inside a value object, or deeper still.

using Cratis.Chronicle.Compliance.GDPR;
[PII]
public record PiiAttrDateOfBirth(string Value) : ConceptAs<string>(Value)
{
public static implicit operator PiiAttrDateOfBirth(string value) => new(value);
}
// The concept sits one level down, inside a value object.
public record PiiAttrVerifiedDateOfBirth(PiiAttrDateOfBirth DateOfBirth, string VerifiedBy);
// Chronicle still finds it: dateOfBirth.dateOfBirth is encrypted, verifiedBy is not.
public record PiiAttrExpressVerification(string Name, PiiAttrVerifiedDateOfBirth DateOfBirth);

Here only dateOfBirth.dateOfBirth is encrypted. verifiedBy sits beside it in the same value object and stays readable, because the concept carries the classification, not its container.

This is what makes concept-level [PII] worth reaching for: you declare the rule once on the type, and it holds no matter how the value is later composed into events and read models.

Applying [PII] to a type that inherits from EventSourceId or EventSourceId<T> throws PIINotSupportedOnEventSourceId at runtime:

using Cratis.Chronicle.Compliance.GDPR;
using Cratis.Chronicle.Events;
// ❌ This will throw PIINotSupportedOnEventSourceId
[PII]
public record PiiAttrEmployeeId(Guid Value) : EventSourceId<Guid>(Value);

Event source identifiers are used to look up encryption keys and group events. Encrypting them would make key lookup impossible. If the identifier itself is sensitive, use a non-sensitive surrogate (such as a random Guid) as the event source identifier and store the sensitive value in a [PII]-marked event property.

One [PII] marker anywhere turns the compliance pass on for the whole document, so every other value in that event or read model is visited too. Most of them are ordinary properties the schema declares, and Chronicle walks straight through them looking for markers.

A geospatial value is different. Point, LineString and Polygon are objects on the wire — GeoJSON, a type and a coordinates pair — but Chronicle stores and materializes each as one typed value, so its schema is a leaf carrying only a format and those wire members belong to the type’s own converter. The compliance pass stops there and leaves the value exactly as it is: written and read back verbatim, in the clear.

That is normally what you want, because a venue sitting beside a [PII] organizer name is public data. Marking the geospatial property [PII] is not a supported alternative — see Geospatial values alongside PII for why.

A [PII] marker on a collection property behaves differently from one on a value object. The collection is encrypted as a single value rather than element by element, and its shape is restored when it is released. That keeps the data protected, but the individual elements are not separately encrypted and cannot be queried or indexed on the server.

When you need per-element encryption, model the collection as its own person-scoped read model keyed by the subject and join at the query edge, rather than nesting it inside a larger document.

The details parameter is a free-text description stored in the event schema. It is never used for encryption — it exists solely to record why a value is classified as PII for compliance documentation and auditing purposes.

using Cratis.Chronicle.Compliance.GDPR;
[PII("Full legal name — required for contract identification")]
public record PiiAttrLegalName(string Value) : ConceptAs<string>(Value);