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> { static readonly valueType = 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> { static readonly valueType = 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> { static readonly valueType = String;
constructor(value: string) { super(value); }}Optional constructor parameters
Section titled “Optional constructor parameters”In the .NET client, a concept parameter that defaults to null retains its underlying type and
[PII] metadata in the precise schema. Supplying a value therefore encrypts it just as it does
for a required parameter. Read models always use this shape; event registration uses it when the
kernel advertises precise event type schema support.
If an older schema omitted the classification, adding it does not encrypt earlier values. See Adding PII to an existing event type before assuming that erasure protects that history.
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> { static readonly valueType = 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';import { field } from '@cratis/fundamentals';
// ✅ 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 { @field(PiiConceptsNationalIdNumber) readonly nationalId: PiiConceptsNationalIdNumber; @field(PiiConceptsPersonName) readonly name: PiiConceptsPersonName;
constructor(nationalId: PiiConceptsNationalIdNumber, name: PiiConceptsPersonName) { this.nationalId = nationalId; this.name = name; }}