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 — automatic PII lineage
Section titled “Projections — automatic PII lineage”Projection-backed read models benefit from automatic PII lineage. The kernel knows which read model properties are mapped from PII event properties and encrypts them transparently before writing to storage. No [PII] attribute is needed on the read model type.
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, string Name, // mapped from ComplianceReadModelsPersonName — stored encrypted at rest 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, readModel } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@pii()class ComplianceReadModelsPersonName extends ConceptAs<string> { constructor(value: string) { super(value); }}
@eventType()class ComplianceReadModelsEmployeeRegistered { constructor(readonly name: ComplianceReadModelsPersonName, readonly department: string) {}}
// Chronicle's projection pipeline carries PII lineage automatically from the source event// property into the read model - no @pii() is needed here even though `name` is a plain// string. It is still encrypted at rest because it came from a PII-marked event property.@readModel()@fromEvent(ComplianceReadModelsEmployeeRegistered)class ComplianceReadModelsEmployee { name = ''; department = '';}The Name property is stored encrypted in MongoDB because PersonName is a PII-marked type. 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 use arbitrary C# logic to compute state. The kernel cannot infer which properties derive from PII fields because the transformation is opaque. You must annotate PII properties on the read model record with [PII].
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);}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';
@eventType()class ComplianceReadModelsPatientAdmitted { constructor(readonly name: string, readonly admittedAt: Date) {}}
// 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.
- Trigger a re-projection or re-reduction of the affected read models.
After key deletion, decryption of PII properties for that subject fails gracefully — Chronicle writes empty values for erased PII fields. Existing read model documents that were written before erasure continue to contain encrypted ciphertext until they are re-projected.
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);}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> { return this.eventStore.readModels.getInstanceById(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.
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 | An empty value — 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 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.