Skip to content

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 needed

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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.

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 automatic

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub