Declaring PII on a concept
The most robust way to declare that a value is personally identifiable is to mark the ConceptAs<T> type itself with [PII]. Doing so means the declaration lives exactly once — on the type — and every event property that uses that type is automatically encrypted, regardless of where or how many times it appears across your event model.
Why concept-level declaration is preferred
Section titled “Why concept-level declaration is preferred”When you mark a property directly with [PII], you must remember to repeat the attribute on every event or read model that carries that value.
If a new event is added months later and the developer forgets the annotation, a plaintext value is written to the event log with no warning.
A concept type solves this by making the protection part of the type’s identity. You cannot use PersonName without the encryption — the two are inseparable.
using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;
// ❌ Property-level: requires repetition across every event[EventType]public record PiiConceptsComparisonEmployeeRegistered([PII] string Name, string Department);
[EventType]public record PiiConceptsComparisonEmployeeNameChanged([PII] string NewName); // must remember [PII] again
// ✅ Concept-level: declare once, apply everywhere automatically[PII]public record PiiConceptsComparisonPersonName(string Value) : ConceptAs<string>(Value){ public static readonly PiiConceptsComparisonPersonName NotSet = new(string.Empty); public static implicit operator string(PiiConceptsComparisonPersonName name) => name.Value; public static implicit operator PiiConceptsComparisonPersonName(string value) => new(value);}
[EventType]public record PiiConceptsComparisonEmployeeRegisteredGood(PiiConceptsComparisonPersonName Name, string Department); // Name is encrypted
[EventType]public record PiiConceptsComparisonEmployeeNameChangedGood(PiiConceptsComparisonPersonName NewName); // also encrypted, no extra annotation neededimport io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAsimport io.cratis.chronicle.events.EventType
// Property-level: requires repetition across every event@EventTypedata class PiiConceptsComparisonEmployeeRegistered(@Pii val name: String, val department: String)
@EventTypedata class PiiConceptsComparisonEmployeeNameChanged(@Pii val newName: String) // must remember @Pii again
// Concept-level: declare once, apply everywhere automatically@Piidata class PiiConceptsComparisonPersonName(override val value: String) : ConceptAs<String>
// name is encrypted@EventTypedata class PiiConceptsComparisonEmployeeRegisteredGood(val name: PiiConceptsComparisonPersonName, val department: String)
// also encrypted, no extra annotation needed@EventTypedata class PiiConceptsComparisonEmployeeNameChangedGood(val newName: PiiConceptsComparisonPersonName)import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;import io.cratis.chronicle.events.EventType;
// Property-level: requires repetition across every event@EventTyperecord PiiConceptsComparisonEmployeeRegistered(@Pii String name, String department) {}
// must remember @Pii again@EventTyperecord PiiConceptsComparisonEmployeeNameChanged(@Pii String newName) {}
// Concept-level: declare once, apply everywhere automatically@Piirecord PiiConceptsComparisonPersonName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
// name is encrypted@EventTyperecord PiiConceptsComparisonEmployeeRegisteredGood(PiiConceptsComparisonPersonName name, String department) {}
// also encrypted, no extra annotation needed@EventTyperecord PiiConceptsComparisonEmployeeNameChangedGood(PiiConceptsComparisonPersonName newName) {}# Property-level: requires repeating pii/1,2 on every event.defmodule MyApp.Compliance.PiiWithConcepts.EmployeeRegisteredComparison do use Chronicle.Events.EventType, id: "pii-with-concepts-employee-registered-comparison" defstruct [:name, :department]
pii(:name)end
defmodule MyApp.Compliance.PiiWithConcepts.EmployeeNameChangedComparison do use Chronicle.Events.EventType, id: "pii-with-concepts-employee-name-changed-comparison" defstruct [:new_name]
# Easy to forget — a plaintext value would be written with no warning. pii(:new_name)end
# Concept-level: declare once, apply everywhere automatically.defmodule MyApp.Compliance.PiiWithConcepts.PersonNameComparison do use Chronicle.Concept, type: :string pii()end
defmodule MyApp.Compliance.PiiWithConcepts.EmployeeRegisteredComparisonGood do use Chronicle.Events.EventType, id: "pii-with-concepts-employee-registered-comparison-good"
defstruct name: %MyApp.Compliance.PiiWithConcepts.PersonNameComparison{}, department: ""end
defmodule MyApp.Compliance.PiiWithConcepts.EmployeeNameChangedComparisonGood do use Chronicle.Events.EventType, id: "pii-with-concepts-employee-name-changed-comparison-good"
# Also encrypted — no extra annotation needed. defstruct new_name: %MyApp.Compliance.PiiWithConcepts.PersonNameComparison{}endimport { eventType, pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
// ❌ Property-level: requires repetition across every event@eventType()class PiiConceptsComparisonEmployeeRegistered { @pii() name = ''; department = '';}
@eventType()class PiiConceptsComparisonEmployeeNameChanged { @pii() newName = ''; // must remember @pii() again}
// ✅ Concept-level: declare once, apply everywhere automatically@pii()class PiiConceptsComparisonPersonName extends ConceptAs<string> { constructor(value: string) { super(value); }}
@eventType()class PiiConceptsComparisonEmployeeRegisteredGood { name: PiiConceptsComparisonPersonName = new PiiConceptsComparisonPersonName(''); // encrypted department = '';}
@eventType()class PiiConceptsComparisonEmployeeNameChangedGood { newName: PiiConceptsComparisonPersonName = new PiiConceptsComparisonPersonName(''); // also encrypted, no extra annotation needed}Defining a PII concept type
Section titled “Defining a PII concept type”Follow the standard ConceptAs<T> pattern and add [PII] to the record declaration:
using Cratis.Chronicle.Compliance.GDPR;
[PII]public record PiiConceptsPersonName(string Value) : ConceptAs<string>(Value){ public static readonly PiiConceptsPersonName NotSet = new(string.Empty);
public static implicit operator string(PiiConceptsPersonName name) => name.Value; public static implicit operator PiiConceptsPersonName(string value) => new(value);}import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAs
@Piidata class PiiConceptsPersonName(override val value: String) : ConceptAs<String>import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;
@Piirecord PiiConceptsPersonName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}defmodule MyApp.Compliance.PiiWithConcepts.PersonName do use Chronicle.Concept, type: :string pii()endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii()class PiiConceptsPersonName extends ConceptAs<string> { constructor(value: string) { super(value); }}Another example, documenting why the value is sensitive with the optional details parameter:
using Cratis.Chronicle.Compliance.GDPR;
[PII("National ID number — sensitive personal identifier")]public record PiiConceptsNationalIdNumber(string Value) : ConceptAs<string>(Value){ public static readonly PiiConceptsNationalIdNumber NotSet = new(string.Empty);
public static implicit operator string(PiiConceptsNationalIdNumber id) => id.Value; public static implicit operator PiiConceptsNationalIdNumber(string value) => new(value);}import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAs
@Pii(description = "National ID number — sensitive personal identifier")data class PiiConceptsNationalIdNumber(override val value: String) : ConceptAs<String>import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;
@Pii(description = "National ID number — sensitive personal identifier")record PiiConceptsNationalIdNumber(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}defmodule MyApp.Compliance.PiiWithConcepts.NationalIdNumber do use Chronicle.Concept, type: :string pii("National ID number — sensitive personal identifier")endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii('National ID number — sensitive personal identifier')class PiiConceptsNationalIdNumber extends ConceptAs<string> { constructor(value: string) { super(value); }}Placement
Section titled “Placement”Concept types belong in the feature folder they are primarily associated with, or at the feature root if they are shared across slices. Do not create a separate Concepts/ folder — keep concepts co-located with the code that uses them.
Features/├── Employees/│ ├── PersonName.cs ← shared across Registration and Updates slices│ ├── Registration/│ │ ├── Registration.cs ← uses PersonName│ └── Updates/│ ├── Updates.cs ← also uses PersonName — encryption is automaticCombining with details
Section titled “Combining with details”Use the optional details parameter on [PII] to document the legal basis or purpose of the PII classification. This is stored in the event schema and can be surfaced by compliance tooling.
using Cratis.Chronicle.Compliance.GDPR;
[PII("Collected under GDPR Art. 6(1)(b) — necessary for contract performance. Retention: contract duration + 7 years.")]public record PiiConceptsLegalName(string Value) : ConceptAs<string>(Value){ public static readonly PiiConceptsLegalName NotSet = new(string.Empty);
public static implicit operator string(PiiConceptsLegalName name) => name.Value; public static implicit operator PiiConceptsLegalName(string value) => new(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. " + "Retention: contract duration + 7 years.")data class PiiConceptsLegalName(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. " + "Retention: contract duration + 7 years.")record PiiConceptsLegalName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}defmodule MyApp.Compliance.PiiWithConcepts.LegalName do use Chronicle.Concept, type: :string
pii( "Collected under GDPR Art. 6(1)(b) — necessary for contract performance. " <> "Retention: contract duration + 7 years." )endimport { pii } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii('Collected under GDPR Art. 6(1)(b) — necessary for contract performance. Retention: contract duration + 7 years.')class PiiConceptsLegalName extends ConceptAs<string> { constructor(value: string) { super(value); }}What cannot be marked PII
Section titled “What cannot be marked PII”EventSourceId types
Section titled “EventSourceId types”Any type inheriting from EventSourceId or EventSourceId<T> cannot be marked with [PII]. Chronicle throws PIINotSupportedOnEventSourceId if you attempt this.
using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;
// ❌ Throws PIINotSupportedOnEventSourceId[PII]public record PiiConceptsEmployeeId(Guid Value) : EventSourceId<Guid>(Value);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.EventSourceId
// Throws PiiNotSupportedOnEventSourceId@Piidata class PiiConceptsEmployeeId(override val value: String) : EventSourceIdimport io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.EventSourceId;
// Throws PiiNotSupportedOnEventSourceId@Piirecord PiiConceptsEmployeeId(String value) implements EventSourceId { @Override public String getValue() { return value; }}# Raises ArgumentError at compile time — any Chronicle.Concept declared with# event_source_id: true cannot also declare pii/0,1.## defmodule MyApp.Compliance.PiiWithConcepts.EmployeeId do# use Chronicle.Concept, type: :uuid, event_source_id: true# pii()# endimport { pii } from '@cratis/chronicle';
// TypeScript has no dedicated EventSourceId<T> type to mark PII on directly - the event// source identifier is always the conventional 'eventSourceId' property, and marking it// @pii() throws PIINotSupportedOnEventSourceId for the same reason C# forbids [PII] on a// concept deriving from EventSourceId<T>.class PiiConceptsEmployeeId { @pii() eventSourceId = '';}Use a non-sensitive surrogate key as the event source identifier and store sensitive identity values in a separate event property:
using Cratis.Chronicle.Events;
// ✅ Surrogate key as event source identifierpublic record PiiConceptsSurrogateEmployeeId(Guid Value) : EventSourceId<Guid>(Value){ public static PiiConceptsSurrogateEmployeeId New() => new(Guid.NewGuid());}
// ✅ Sensitive value stored in a PII-marked concept type[EventType]public record PiiConceptsSurrogateEmployeeRegistered(PiiConceptsNationalIdNumber NationalId, PiiConceptsPersonName Name);import io.cratis.chronicle.concepts.EventSourceIdimport io.cratis.chronicle.events.EventType
// Surrogate key as event source identifier - not marked @Piidata class PiiConceptsSurrogateEmployeeId(override val value: String) : EventSourceId
// Sensitive values stored in PII-marked concept types instead@EventTypedata class PiiConceptsSurrogateEmployeeRegistered( val nationalId: PiiConceptsNationalIdNumber, val name: PiiConceptsPersonName)import io.cratis.chronicle.concepts.EventSourceId;import io.cratis.chronicle.events.EventType;
// Surrogate key as event source identifier - not marked @Piirecord PiiConceptsSurrogateEmployeeId(String value) implements EventSourceId { @Override public String getValue() { return value; }}
// Sensitive values stored in PII-marked concept types instead@EventTyperecord PiiConceptsSurrogateEmployeeRegistered( PiiConceptsNationalIdNumber nationalId, PiiConceptsPersonName name) {}# Surrogate key as event source identifier — no pii declared.defmodule MyApp.Compliance.PiiWithConcepts.SurrogateEmployeeId do use Chronicle.Concept, type: :uuid, event_source_id: trueend
defmodule MyApp.Compliance.PiiWithConcepts.SurrogateNationalId do use Chronicle.Concept, type: :string pii("National ID number — sensitive personal identifier")end
defmodule MyApp.Compliance.PiiWithConcepts.SurrogatePersonName do use Chronicle.Concept, type: :string pii()end
# Sensitive values stored in PII-marked concept fields instead.defmodule MyApp.Compliance.PiiWithConcepts.SurrogateEmployeeRegistered do use Chronicle.Events.EventType, id: "pii-with-concepts-surrogate-employee-registered"
defstruct national_id: %MyApp.Compliance.PiiWithConcepts.SurrogateNationalId{}, name: %MyApp.Compliance.PiiWithConcepts.SurrogatePersonName{}endimport { eventType, Guid } from '@cratis/chronicle';
// ✅ Surrogate key as event source identifier - TypeScript event source identifiers are// plain strings, so a randomly generated Guid works well with no dedicated identity type// required.function createSurrogateEmployeeId(): string { return Guid.create().toString();}
// ✅ Sensitive values stored in PII-marked concept properties instead@eventType()class PiiConceptsSurrogateEmployeeRegistered { constructor(readonly nationalId: PiiConceptsNationalIdNumber, readonly name: PiiConceptsPersonName) {}}