Read models and PII
Chronicle stores PII fields encrypted at rest in both the event log and managed read models (projections and reducers). The encryption and decryption is handled automatically by the kernel — your application code works with plaintext values at all times.
Observer decryption
Section titled “Observer decryption”All observers — reactors, webhooks, reducers, and projections — receive decrypted events from a single, central decryption point in Observer.Handle(). This means encryption is applied consistently regardless of the observer type, and no observer implementation needs to handle decryption itself.
The compliance identifier used for key lookup follows this rule: if an explicit Subject was set on the event at append time, that value is used. Otherwise, the EventSourceId is used as the fallback identifier. This mirrors the encryption key that was used when the event was originally written to the event log.
Projections — [PII] required on the read model property
Section titled “Projections — [PII] required on the read model property”A projection copies values from events into a read model, but the kernel decides what to encrypt from the read model’s own schema, not from the events the values came from. Marking an event property as PII does not mark the read model property it is mapped to. Without [PII] on that read model property, the projected value is stored in plain text, and it stays readable after the subject’s key is erased.
Mark every read model property that holds PII, either with [PII] on the property or by giving it a concept type that is itself marked [PII] (see Declaring PII on a concept).
using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[PII]public record ComplianceReadModelsPersonName(string Value) : ConceptAs<string>(Value){ public static readonly ComplianceReadModelsPersonName NotSet = new(string.Empty); public static implicit operator string(ComplianceReadModelsPersonName name) => name.Value; public static implicit operator ComplianceReadModelsPersonName(string value) => new(value);}
[EventType]public record ComplianceReadModelsEmployeeRegistered(ComplianceReadModelsPersonName Name, string Department);
[FromEvent<ComplianceReadModelsEmployeeRegistered>]public record ComplianceReadModelsEmployee( [Key] Guid Id, [PII] string Name, // mapped from ComplianceReadModelsPersonName; [PII] here is what stores it encrypted string Department);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.concepts.ConceptAsimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.readModels.ReadModel
@Piidata class ComplianceReadModelsPersonName(override val value: String) : ConceptAs<String>
@EventTypedata class ComplianceReadModelsEmployeeRegistered(val name: ComplianceReadModelsPersonName, val department: String)
@ReadModel@FromEvent(ComplianceReadModelsEmployeeRegistered::class)data class ComplianceReadModelsEmployee( val id: String = "", val name: String = "", // mapped from ComplianceReadModelsPersonName - stored encrypted at rest val department: String = "")import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.concepts.ConceptAs;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.readModels.ReadModel;
@Piirecord ComplianceReadModelsPersonName(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
@EventTyperecord ComplianceReadModelsEmployeeRegistered(ComplianceReadModelsPersonName name, String department) {}
@ReadModel@FromEvent(eventType = ComplianceReadModelsEmployeeRegistered.class)class ComplianceReadModelsEmployee { public String id = ""; public String name = ""; // mapped from ComplianceReadModelsPersonName - stored encrypted at rest public String department = "";}defmodule MyApp.Compliance.ReadModels.PersonName do use Chronicle.Concept, type: :string pii()end
defmodule MyApp.Compliance.ReadModels.EmployeeRegistered do use Chronicle.Events.EventType, id: "compliance-read-models-employee-registered"
defstruct name: %MyApp.Compliance.ReadModels.PersonName{}, department: ""end
defmodule MyApp.Compliance.ReadModels.Employee do use Chronicle.ReadModels.ReadModel
# name reuses the same concept as the source event, so it is stored # encrypted at rest without repeating the pii declaration here. defstruct id: nil, name: %MyApp.Compliance.ReadModels.PersonName{}, department: nil
from MyApp.Compliance.ReadModels.EmployeeRegistered, set: [id: :event_source_id, name: :name, department: :department]endimport { eventType, fromEvent, pii } from '@cratis/chronicle';import { ConceptAs, field } from '@cratis/fundamentals';
@pii()export class ComplianceReadModelsPersonName extends ConceptAs<string> { static readonly valueType = String;
constructor(value: string) { super(value); }}
@eventType()export class ComplianceReadModelsEmployeeRegistered { @field(ComplianceReadModelsPersonName) name: ComplianceReadModelsPersonName; @field(String) department: string;
constructor(name: ComplianceReadModelsPersonName, department: string) { this.name = name; this.department = department; }}
// The kernel encrypts a projected read model from the read model's own schema, so mark the// personal property with @pii() here as well. Without it, `name` is stored in plain text even// though it came from a PII-marked event property.@fromEvent(ComplianceReadModelsEmployeeRegistered)export class ComplianceReadModelsEmployee { @pii() @field(String) name = ''; @field(String) department = '';}The Name property is mapped from PersonName on the event, and carries [PII] itself, so it is stored encrypted in MongoDB. When a query returns Employee records, the kernel decrypts the values before they reach the caller.
Reducers — explicit [PII] required
Section titled “Reducers — explicit [PII] required”Reducers follow the same rule as projections: you must annotate PII properties on the read model record with [PII]. Reducers also use arbitrary C# logic to compute state, so there is no mapping from event properties to follow.
using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
[EventType]public record ComplianceReadModelsPatientAdmitted(ComplianceReadModelsPersonName Name, DateTimeOffset AdmittedAt);
public record ComplianceReadModelsPatientSummary(Guid PatientId, [PII] string Name, DateTimeOffset LastAdmittedAt);
public class ComplianceReadModelsPatientSummaryReducer : IReducerFor<ComplianceReadModelsPatientSummary>{ public ComplianceReadModelsPatientSummary Admitted(ComplianceReadModelsPatientAdmitted @event, ComplianceReadModelsPatientSummary? current, EventContext context) => new( Guid.Parse(context.EventSourceId.Value), @event.Name, @event.AdmittedAt);}import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport java.time.Instant
@EventTypedata class ComplianceReadModelsPatientAdmitted(val name: ComplianceReadModelsPersonName, val admittedAt: Instant)
@ReadModeldata class ComplianceReadModelsPatientSummary( val patientId: String = "", @Pii val name: String = "", val lastAdmittedAt: Instant = Instant.EPOCH)
@Reducerclass ComplianceReadModelsPatientSummaryReducer { fun admitted( event: ComplianceReadModelsPatientAdmitted, current: ComplianceReadModelsPatientSummary?, context: EventContext ): ComplianceReadModelsPatientSummary = ComplianceReadModelsPatientSummary( patientId = context.eventSourceId, name = event.name.value, lastAdmittedAt = event.admittedAt )}import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
import java.time.Instant;
@EventTyperecord ComplianceReadModelsPatientAdmitted(ComplianceReadModelsPersonName name, Instant admittedAt) {}
@ReadModelrecord ComplianceReadModelsPatientSummary(String patientId, @Pii String name, Instant lastAdmittedAt) { ComplianceReadModelsPatientSummary() { this("", "", Instant.EPOCH); }}
@Reducerclass ComplianceReadModelsPatientSummaryReducer { ComplianceReadModelsPatientSummary admitted( ComplianceReadModelsPatientAdmitted event, ComplianceReadModelsPatientSummary current, EventContext context) { return new ComplianceReadModelsPatientSummary( context.getEventSourceId(), event.name().value(), event.admittedAt()); }}defmodule MyApp.Compliance.ReadModels.ReducerPersonName do use Chronicle.Concept, type: :string pii()end
defmodule MyApp.Compliance.ReadModels.PatientAdmitted do use Chronicle.Events.EventType, id: "compliance-read-models-patient-admitted"
defstruct name: %MyApp.Compliance.ReadModels.ReducerPersonName{}, admitted_at: nilend
defmodule MyApp.Compliance.ReadModels.PatientSummary do use Chronicle.ReadModels.ReadModel
defstruct patient_id: nil, name: "", last_admitted_at: nil
# A reducer assigns name as a plain string, so it needs its own explicit # pii/1,2 declaration — it is not itself a Chronicle.Concept field here. pii(:name)end
defmodule MyApp.Compliance.ReadModels.PatientSummaryReducer do use Chronicle.Reducers.Reducer, model: MyApp.Compliance.ReadModels.PatientSummary
@handles MyApp.Compliance.ReadModels.PatientAdmitted
@impl true def reduce(%MyApp.Compliance.ReadModels.PatientAdmitted{} = event, _model, context) do %MyApp.Compliance.ReadModels.PatientSummary{ patient_id: context.event_source_id, name: event.name, last_admitted_at: context.occurred } endendimport { eventType, pii, reducer } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ComplianceReadModelsPatientAdmitted { @field(String) readonly name: string; @field(Date) readonly admittedAt: Date;
constructor(name: string, admittedAt: Date) { this.name = name; this.admittedAt = admittedAt; }}
// Reducer-backed read models do not inherit PII lineage from the source event automatically -// mark the property explicitly.class ComplianceReadModelsPatientSummary { @pii() name = ''; lastAdmittedAt = new Date();}
@reducer('PatientSummaryReducer', undefined, ComplianceReadModelsPatientSummary)class ComplianceReadModelsPatientSummaryReducer { async patientAdmitted( event: ComplianceReadModelsPatientAdmitted, current?: ComplianceReadModelsPatientSummary ): Promise<ComplianceReadModelsPatientSummary> { return { name: event.name, lastAdmittedAt: event.admittedAt }; }}The [PII] attribute on Name tells the kernel to encrypt that property before storage and decrypt it on retrieval.
Stored subjects
Section titled “Stored subjects”Every managed read model document written by Chronicle contains a reserved __subject field. It stores the default compliance identifier used for PII on that document. Existing single-subject documents need no other metadata and remain compatible.
A projection can compose PII from events belonging to different subjects. Chronicle derives that ownership from the events that populate each property and stores only the exceptions to the default in a reserved __subjects object:
{ "__subject": "employee-42", "__subjects": { "advisorName": "advisor-17" }}In this example, advisorName is encrypted, released, and erased under advisor-17; every other PII property falls back to employee-42. The map is maintained automatically as projection events replace values. No read model attributes or manual migration are required. Rows written before this capability have no __subjects field and continue using __subject for every property.
Do not declare properties named
__subjector__subjectsin your read model records. Chronicle reserves both names for internal use.
GDPR erasure
Section titled “GDPR erasure”Deleting an encryption key is the Chronicle mechanism for GDPR erasure:
- Delete the key for the subject (the data subject’s identifier) via the Compliance API — see Erasing a subject.
- Read models need no further step to stop exposing the data: reading them releases each PII value with the subject’s key, and with the key gone the value comes back empty.
Read model documents written before the erasure still hold the ciphertext. Nothing can decrypt it once the key is gone, which is what crypto-shredding relies on.
For full erasure of event content, combine key deletion with event redaction.
Querying
Section titled “Querying”Read model queries are transparent to PII encryption. No changes to query code are needed:
using Cratis.Chronicle;
public class ComplianceReadModelsEmployeeService(IEventStore eventStore){ public Task<ComplianceReadModelsEmployee> GetEmployee(Guid id) => eventStore.ReadModels.GetInstanceById<ComplianceReadModelsEmployee>(id);}import io.cratis.chronicle.IEventStore
class ComplianceReadModelsEmployeeService(private val eventStore: IEventStore) { suspend fun getEmployee(id: String): ComplianceReadModelsEmployee? = eventStore.readModels.getInstanceByKey(ComplianceReadModelsEmployee::class, id)}import io.cratis.chronicle.java.BlockingEventStore;
class ComplianceReadModelsEmployeeService { private final BlockingEventStore eventStore;
ComplianceReadModelsEmployeeService(BlockingEventStore eventStore) { this.eventStore = eventStore; }
ComplianceReadModelsEmployee getEmployee(String id) { return eventStore.getReadModels().getInstanceByKey(ComplianceReadModelsEmployee.class, id); }}defmodule MyApp.Compliance.ReadModels.QueryingPersonName do use Chronicle.Concept, type: :string pii()end
defmodule MyApp.Compliance.ReadModels.QueryingEmployee do use Chronicle.ReadModels.ReadModel
defstruct id: nil, name: %MyApp.Compliance.ReadModels.QueryingPersonName{}, department: nilend
defmodule MyApp.Compliance.ReadModels.EmployeeService do # PII decryption is transparent here — the caller just gets the plain, # decrypted value back. def get_employee(id) do Chronicle.ReadModels.get(MyApp.Compliance.ReadModels.QueryingEmployee, id) endendimport { IEventStore } from '@cratis/chronicle';
class ComplianceReadModelsEmployeeService { constructor(private readonly eventStore: IEventStore) {}
getEmployee(id: string): Promise<ComplianceReadModelsEmployee | null> { return this.eventStore.readModels.findInstanceById(ComplianceReadModelsEmployee, id); }}Chronicle decrypts PII fields automatically before returning results to the caller. When the encryption key has been deleted for a subject, Name returns an empty string — the caller receives an Employee record with an empty name, never an exception or partial result. Properties that are not strings release as the empty value of their type, listed in the table below.
That automatic decryption covers IReadModels’ query methods. For an instance that arrived some other way — built from raw storage, restored from a cache, or delivered through Watch, which streams changes without releasing them — call IReadModels.Release yourself; see Releasing PII.
One property never fails the whole read
Section titled “One property never fails the whole read”Decryption is resolved per property, and a property that cannot be read never fails the read model or the query returning it:
| The stored value | What the caller gets |
|---|---|
| Encrypted under this subject | The decrypted value. |
| Encrypted, but the subject’s key was deleted | The empty value of the property’s type — the erasure case above. |
| Never encrypted under this subject | The value, untouched. |
| Encrypted under a different subject and missing ownership metadata | An empty value for that property, plus an error in the Kernel log naming the property, the subject, and the likely cause. |
The empty value of a property’s type is an empty string for a string, 0 or false for a non-nullable number or boolean, null for a nullable one, and an empty collection or object for a collection or value object. An erased value therefore cannot be told apart from one genuinely stored as 0, false or empty; whether to make them distinguishable is an open decision in issue 4620.
The third row matters when a [PII]-typed property is resolved in memory at the query edge — for display on a view whose own compliance subject is not that person — or when a property was marked [PII] after values had already been stored. Chronicle recognizes the shape of the values it encrypts, so a value it never encrypted is passed through rather than blanked or rejected.
The fourth row can occur for legacy data that was written before per-property ownership was tracked, or for data written outside Chronicle’s managed projection sinks. New projection writes keep the ownership map with the ciphertext, so joined PII releases under the subject of the event it came from.
Composing read models outside Chronicle
Section titled “Composing read models outside Chronicle”Per-property ownership is storage metadata maintained by Chronicle’s projection pipeline. If application code manually constructs an object from raw ciphertext, IReadModels.Release can only resolve the object’s own [Subject] member or Id and cannot infer where each copied value originated. Prefer querying managed read models through Chronicle so their stored ownership metadata is honored before composition.