Skip to content

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.

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.

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

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

The [PII] attribute on Name tells the kernel to encrypt that property before storage and decrypt it on retrieval.

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 __subject or __subjects in your read model records. Chronicle reserves both names for internal use.

Deleting an encryption key is the Chronicle mechanism for GDPR erasure:

  1. Delete the key for the subject (the data subject’s identifier) via the Compliance API.
  2. 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.

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

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.

Decryption is resolved per property, and a property that cannot be read never fails the read model or the query returning it:

The stored valueWhat the caller gets
Encrypted under this subjectThe decrypted value.
Encrypted, but the subject’s key was deletedAn empty value — the erasure case above.
Never encrypted under this subjectThe value, untouched.
Encrypted under a different subject and missing ownership metadataAn 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.

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.