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;

View C# snippet source on GitHub

You may add [PII] to a property in an existing generation without introducing a new generation. The kernel accepts the added compliance metadata and logs a warning naming the event type, generation and schema property path. Adding [Encrypted] security metadata follows the same rule. Removing or changing existing protection metadata within that generation is refused.

Only subsequent appends use the new encryption policy. Earlier values remain plaintext and remain readable even after the subject’s encryption keys are erased. A schema update does not rewrite history, and introducing a new generation does not encrypt the original stored generation either.

If historical plaintext must no longer be available, redact those events. Treat the registration warning as a prompt to review the existing history, not as confirmation that it has been protected. During a rolling deployment, writers must retain protection metadata once it has been registered; an older writer without it is refused.

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);

View C# snippet source on GitHub

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. The other clients do not perform every check:

  • Kotlin/Java, TypeScript, and Elixir don’t verify that a [PII]-marked type actually extends the client’s ConceptAs<T> equivalent.
  • Kotlin/Java throw PiiNotSupportedOnEventSourceId when @Pii is applied to a subclass of EventSourceId.
  • Elixir raises an ArgumentError at compile time when pii/0,1 is declared on a concept with event_source_id: true.
  • TypeScript throws PIINotSupportedOnEventSourceId when @pii() is applied to a property named eventSourceId, but not when an event-source identifier type is marked.

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);

View C# snippet source on GitHub

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);
}

View C# snippet source on GitHub

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);

View C# snippet source on GitHub

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);

View C# snippet source on GitHub

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);

View C# snippet source on GitHub

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);

View C# snippet source on GitHub