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;import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.compliance.Pii;# No explicit import is required. `pii/1,2` becomes available automatically# from `use Chronicle.Events.EventType` or `use Chronicle.ReadModels.ReadModel`,# and `pii/0,1` becomes available automatically from `use Chronicle.Concept`.import { pii } from '@cratis/chronicle';Marker signatures
Section titled “Marker signatures”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 = "")
// TypeScriptexport function pii(details?: string): PropertyDecorator & ClassDecorator
// Elixirdefmacro 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);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAs
@Pii(description = "Collected under GDPR Art. 6(1)(b) — necessary for contract performance")data class PiiAttrPersonName(override val value: String) : ConceptAs<String>import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;
@Pii(description = "Collected under GDPR Art. 6(1)(b) — necessary for contract performance")record PiiAttrPersonName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}defmodule MyApp.Compliance.Pii.PersonNameWithDetails do use Chronicle.Concept, type: :string pii("Collected under GDPR Art. 6(1)(b) — necessary for contract performance")endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii('Collected under GDPR Art. 6(1)(b) — necessary for contract performance')class PiiAttrPersonName extends ConceptAs<string> { constructor(value: string) { super(value); }}Where you can mark PII
Section titled “Where you can mark PII”| Target | Supported | Notes |
|---|---|---|
ConceptAs<T> class | Yes | Preferred approach — marks the concept type itself as PII |
| Event property | Yes | Marks a single property of an event as PII |
| Value object class | Yes | Marks every value the type holds as PII, however deeply nested |
| Property typed as a value object | Yes | Same effect, scoped to that one property |
| Collection property | Yes | The collection is encrypted as a whole — see Collections are encrypted as a whole |
Geospatial (Point, LineString, Polygon) | No | Never 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 dictionary | No | Chronicle cannot classify values inside either and fails rather than store them unprotected |
EventSourceId or EventSourceId<T> | No | Throws 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.
Applying to an event property
Section titled “Applying to an event property”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);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.events.EventType
@EventTypedata class PiiAttrEmployeeRegistered( @Pii val firstName: String, @Pii val lastName: String, val department: String)import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.events.EventType;
@EventTyperecord PiiAttrEmployeeRegistered( @Pii String firstName, @Pii String lastName, String department) {}defmodule MyApp.Events.PiiAttrEmployeeRegistered do use Chronicle.Events.EventType, id: "pii-attr-employee-registered"
defstruct [:first_name, :last_name, :department]
pii(:first_name) pii(:last_name)endimport { eventType, pii } from '@cratis/chronicle';
@eventType()class PiiAttrEmployeeRegistered { @pii() firstName = ''; @pii() lastName = ''; department = '';}When this event is written, FirstName and LastName are encrypted. Department is stored as plaintext.
Applying to a ConceptAs type
Section titled “Applying to a ConceptAs type”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);}import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAs
@Piidata class PiiAttrConceptPersonName(override val value: String) : ConceptAs<String>import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;
@Piirecord PiiAttrConceptPersonName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}defmodule MyApp.Compliance.Pii.PersonName do use Chronicle.Concept, type: :string pii()endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii()class PiiAttrConceptPersonName extends ConceptAs<string> { constructor(value: string) { super(value); }}Applying to a value object
Section titled “Applying to a value object”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);import io.cratis.chronicle.compliance.Pii
// Every value this type holds is personal, so mark the type once.@Piidata class PiiAttrDiagnosis(val condition: String, val diagnosedBy: String)
// Both condition and diagnosedBy are encrypted wherever a PiiAttrDiagnosis appears.data class PiiAttrPatientRecord(val name: String, val diagnosis: PiiAttrDiagnosis)import io.cratis.chronicle.compliance.Pii;
// Every value this type holds is personal, so mark the type once.@Piirecord PiiAttrDiagnosis(String condition, String diagnosedBy) {}
// Both condition and diagnosedBy are encrypted wherever a PiiAttrDiagnosis appears.record PiiAttrPatientRecord(String name, PiiAttrDiagnosis diagnosis) {}Elixir does not support this workflow yet.import { pii } from '@cratis/chronicle';
// Every value this type holds is personal, so mark the type once.@pii()class PiiAttrDiagnosis { condition = ''; diagnosedBy = '';}
// Both condition and diagnosedBy are encrypted wherever a PiiAttrDiagnosis appears.class PiiAttrPatientRecord { name = ''; diagnosis: PiiAttrDiagnosis = new PiiAttrDiagnosis();}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.
Nesting
Section titled “Nesting”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);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAs
@Piidata class PiiAttrDateOfBirth(override val value: String) : ConceptAs<String>
// The concept sits one level down, inside a value object.data class PiiAttrVerifiedDateOfBirth(val dateOfBirth: PiiAttrDateOfBirth, val verifiedBy: String)
// Chronicle still finds it: dateOfBirth.dateOfBirth is encrypted, verifiedBy is not.data class PiiAttrExpressVerification(val name: String, val dateOfBirth: PiiAttrVerifiedDateOfBirth)import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;
@Piirecord PiiAttrDateOfBirth(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
// The concept sits one level down, inside a value object.record PiiAttrVerifiedDateOfBirth(PiiAttrDateOfBirth dateOfBirth, String verifiedBy) {}
// Chronicle still finds it: dateOfBirth.dateOfBirth is encrypted, verifiedBy is not.record PiiAttrExpressVerification(String name, PiiAttrVerifiedDateOfBirth dateOfBirth) {}defmodule MyApp.Compliance.Pii.DateOfBirth do use Chronicle.Concept, type: :string pii()end
# The concept sits one level down, inside a plain (non-concept) value object.defmodule MyApp.Compliance.Pii.VerifiedDateOfBirth do defstruct date_of_birth: %MyApp.Compliance.Pii.DateOfBirth{}, verified_by: ""end
# Chronicle still finds it: dateOfBirth.dateOfBirth is encrypted, verifiedBy is not.defmodule MyApp.Compliance.Pii.ExpressVerification do use Chronicle.Events.EventType, id: "pii-express-verification" defstruct name: "", date_of_birth: %MyApp.Compliance.Pii.VerifiedDateOfBirth{}endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii()class PiiAttrDateOfBirth extends ConceptAs<string> { constructor(value: string) { super(value); }}
// The concept sits one level down, inside a value object.class PiiAttrVerifiedDateOfBirth { dateOfBirth: PiiAttrDateOfBirth = new PiiAttrDateOfBirth(''); verifiedBy = '';}
// Chronicle still finds it: dateOfBirth.dateOfBirth is encrypted, verifiedBy is not.class PiiAttrExpressVerification { name = ''; dateOfBirth: PiiAttrVerifiedDateOfBirth = new PiiAttrVerifiedDateOfBirth();}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.
Constraints
Section titled “Constraints”EventSourceId is not supported
Section titled “EventSourceId is not supported”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);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.EventSourceId
// This will throw PiiNotSupportedOnEventSourceId@Piidata class PiiAttrEmployeeId(override val value: String) : EventSourceIdimport io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.EventSourceId;
// This will throw PiiNotSupportedOnEventSourceId@Piirecord PiiAttrEmployeeId(String value) implements EventSourceId { @Override public String getValue() { return value; }}# Raises ArgumentError at compile time — PII is not supported on a# Chronicle.Concept declared with event_source_id: true.## defmodule MyApp.Compliance.Pii.EmployeeId do# use Chronicle.Concept, type: :uuid, event_source_id: true# pii()# endimport { pii } from '@cratis/chronicle';
// TypeScript has no dedicated EventSourceId<T> type - the event source identifier is always// the conventional 'eventSourceId' property. Marking it @pii() throws// PIINotSupportedOnEventSourceId at decoration time, for the same reason C# forbids [PII] on// EventSourceId<T>: encrypting it would make its own decryption key unfindable.class PiiAttrEmployeeId { @pii() eventSourceId = '';}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.
Geospatial values are not looked inside
Section titled “Geospatial values are not looked inside”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.
Collections are encrypted as a whole
Section titled “Collections are encrypted as a whole”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.
Details parameter
Section titled “Details parameter”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);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAs
@Pii(description = "Full legal name — required for contract identification")data class PiiAttrLegalName(override val value: String) : ConceptAs<String>import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;
@Pii(description = "Full legal name — required for contract identification")record PiiAttrLegalName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}defmodule MyApp.Compliance.Pii.LegalName do use Chronicle.Concept, type: :string pii("Full legal name — required for contract identification")endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii('Full legal name — required for contract identification')class PiiAttrLegalName extends ConceptAs<string> { constructor(value: string) { super(value); }}