Releasing PII
Most of the time you never think about this. GetInstanceById, GetInstances, and GetSnapshotsById on IReadModels already hand you PII decrypted — a projection is decrypted by the kernel before it reaches you, and a reducer-backed instance is decrypted by the client right before it’s returned. See Read models and PII for how that automatic path works and what it takes to mark a property as PII in the first place.
IReadModels.Release is the same decryption, exposed for the cases that skip that path: an instance you built from raw storage, restored from a cache or message payload, or received somewhere that doesn’t release automatically. One built-in case exists today — Watch<TReadModel>() streams changes to you directly and does not call Release first.
using Cratis.Chronicle;using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
[PII]public record ReleasingPiiRequesterName(string Value) : ConceptAs<string>(Value){ public static readonly ReleasingPiiRequesterName NotSet = new(string.Empty); public static implicit operator string(ReleasingPiiRequesterName name) => name.Value; public static implicit operator ReleasingPiiRequesterName(string value) => new(value);}
[EventType]public record ReleasingPiiSupportTicketOpened(string CustomerId, ReleasingPiiRequesterName RequesterName);
public record ReleasingPiiSupportTicket(string Id, [Subject] string CustomerId, [PII] string RequesterName);
public class ReleasingPiiSupportTicketReducer : IReducerFor<ReleasingPiiSupportTicket>{ public ReleasingPiiSupportTicket Opened(ReleasingPiiSupportTicketOpened @event, ReleasingPiiSupportTicket? current, EventContext context) => new(context.EventSourceId.Value, @event.CustomerId, @event.RequesterName);}import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.readModels.ReadModel
@EventTypedata class SupportTicketOpened(val customerId: String = "", val requesterName: String = "")
/** * [Release][io.cratis.chronicle.readModels.IReadModelsService.release] resolves whose encryption * key to use by looking for a property named `id`, case-insensitive - here that is the ticket's * own key, which is also the customer it belongs to. */@ReadModeldata class ReleasingReadModelSupportTicket( val id: String = "", @Pii val requesterName: String = "")import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord SupportTicketOpened(String customerId, String requesterName) {}
// Release resolves whose encryption key to use by looking for a property named "id",// case-insensitive - here that is the ticket's own key, which is also the customer it belongs to.@ReadModelclass ReleasingReadModelSupportTicket { private String id = ""; @Pii private String requesterName = "";
public String getId() { return id; } public void setId(String id) { this.id = id; }
public String getRequesterName() { return requesterName; } public void setRequesterName(String requesterName) { this.requesterName = requesterName; }}defmodule MyApp.Events.ReleasingPiiSupportTicketOpened do use Chronicle.Events.EventType, id: "releasing-pii-support-ticket-opened"
defstruct [:customer_id, :requester_name]end
defmodule MyApp.ReadModels.ReleasingPiiSupportTicket do use Chronicle.ReadModels.ReadModel
defstruct [:id, :customer_id, :requester_name]
subject :customer_id pii :requester_nameend
defmodule MyApp.Reducers.ReleasingPiiSupportTicketReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.ReleasingPiiSupportTicket
alias MyApp.Events.ReleasingPiiSupportTicketOpened
@handles ReleasingPiiSupportTicketOpened
@impl true def reduce(%ReleasingPiiSupportTicketOpened{} = event, _model, context) do %MyApp.ReadModels.ReleasingPiiSupportTicket{ id: context.event_source_id, customer_id: event.customer_id, requester_name: event.requester_name } endendimport { EventContext, eventType, pii, reducer, subject } from '@cratis/chronicle';
@eventType()class ReleasingPiiSupportTicketOpened { constructor(readonly customerId: string, readonly requesterName: string) {}}
class ReleasingPiiSupportTicket { // The ticket's own id identifies the ticket, not the person the PII belongs to - @subject() // tells release() to use customerId as the encryption key's owner instead. Without it, // release() would fall back to id. id = '';
@subject() customerId = '';
@pii('The name of the person who opened the ticket') requesterName = '';}
@reducer('', undefined, ReleasingPiiSupportTicket)class ReleasingPiiSupportTicketReducer { releasingPiiSupportTicketOpened( event: ReleasingPiiSupportTicketOpened, current: ReleasingPiiSupportTicket | undefined, context: EventContext ): ReleasingPiiSupportTicket { return { id: context.eventSourceId, customerId: event.customerId, requesterName: event.requesterName }; }}RequesterName is [PII]-marked explicitly on the read model — required for a reducer, since Chronicle can’t infer PII lineage through arbitrary reducer logic the way it can for a model-bound projection. CustomerId carries [Subject]: the ticket’s own Id identifies the ticket, not the person the PII belongs to, so Release needs to be told which property to use as the encryption key’s owner. A property having the Subject type does not select it by itself; the attribute is what declares the role.
Release a single instance
Section titled “Release a single instance”using Cratis.Chronicle;
public class ReleasingPiiSupportTicketService(IEventStore eventStore){ public Task<ReleasingPiiSupportTicket> Release(ReleasingPiiSupportTicket ticket) => eventStore.ReadModels.Release(ticket);}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class ReleasingSingleInstanceSupportTicket(val id: String = "", val requesterName: String = "")
suspend fun release(store: IEventStore, ticket: ReleasingSingleInstanceSupportTicket): ReleasingSingleInstanceSupportTicket = store.readModels.release(ticket)import io.cratis.chronicle.EventStore;import io.cratis.chronicle.readModels.ReadModel;
import io.cratis.chronicle.java.ReadModelsJavaBridge;
@ReadModelclass ReleasingSingleInstanceSupportTicket { private String id = ""; private String requesterName = "";
public String getId() { return id; } public void setId(String id) { this.id = id; }
public String getRequesterName() { return requesterName; } public void setRequesterName(String requesterName) { this.requesterName = requesterName; }}
class ReadModelsReleasingPiiSingleInstance { ReleasingSingleInstanceSupportTicket release(EventStore store, ReleasingSingleInstanceSupportTicket ticket) { return ReadModelsJavaBridge.release(store.getReadModels(), ticket); }}Elixir does not support this workflow yet.import { IEventStore } from '@cratis/chronicle';
class ReleasingPiiSupportTicketService { constructor(private readonly store: IEventStore) {}
release(ticket: ReleasingPiiSupportTicket): Promise<ReleasingPiiSupportTicket> { return this.store.readModels.release(ReleasingPiiSupportTicket, ticket); }}Release a collection
Section titled “Release a collection”Releasing more than one instance at once resolves the subject for each independently, so a single batch can freely mix data belonging to different people.
using Cratis.Chronicle;
public class ReleasingPiiSupportTicketBatchService(IEventStore eventStore){ public Task<IEnumerable<ReleasingPiiSupportTicket>> ReleaseAll(IEnumerable<ReleasingPiiSupportTicket> tickets) => eventStore.ReadModels.Release(tickets);}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class ReleasingCollectionSupportTicket(val id: String = "", val requesterName: String = "")
/** * Releases more than one instance at once - the subject for each is resolved independently, so * a single batch can freely mix data belonging to different people. */suspend fun releaseAll(store: IEventStore, tickets: List<ReleasingCollectionSupportTicket>): List<ReleasingCollectionSupportTicket> = store.readModels.releaseMany(tickets)import io.cratis.chronicle.EventStore;import io.cratis.chronicle.readModels.ReadModel;
import java.util.List;
import io.cratis.chronicle.java.ReadModelsJavaBridge;
@ReadModelclass ReleasingCollectionSupportTicket { private String id = ""; private String requesterName = "";
public String getId() { return id; } public void setId(String id) { this.id = id; }
public String getRequesterName() { return requesterName; } public void setRequesterName(String requesterName) { this.requesterName = requesterName; }}
class ReadModelsReleasingPiiCollection { // Releases more than one instance at once - the subject for each is resolved independently. List<ReleasingCollectionSupportTicket> releaseAll(EventStore store, List<ReleasingCollectionSupportTicket> tickets) { return ReadModelsJavaBridge.releaseMany(store.getReadModels(), tickets); }}Elixir does not support this workflow yet.import { IEventStore } from '@cratis/chronicle';
class ReleasingPiiSupportTicketBatchService { constructor(private readonly store: IEventStore) {}
releaseAll(tickets: ReleasingPiiSupportTicket[]): Promise<ReleasingPiiSupportTicket[]> { return this.store.readModels.releaseMany(ReleasingPiiSupportTicket, tickets); }}Release while watching for changes
Section titled “Release while watching for changes”using Cratis.Chronicle;
public class ReleasingPiiSupportTicketWatcher(IEventStore eventStore){ public IDisposable Start() => eventStore.ReadModels.Watch<ReleasingPiiSupportTicket>().Subscribe(async changeset => { if (changeset.Removed || changeset.ReadModel is null) { return; }
var ticket = await eventStore.ReadModels.Release(changeset.ReadModel); Console.WriteLine($"{changeset.ModelKey}: {ticket.RequesterName}"); });}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModelimport kotlinx.coroutines.flow.collect
@ReadModeldata class ReleasingWatchSupportTicket(val id: String = "", val requesterName: String = "")
/** * [io.cratis.chronicle.readModels.IReadModelsService.watch] is the one built-in read that does * not release PII automatically - release each change yourself as it arrives. */suspend fun watchTickets(store: IEventStore) { store.readModels.watch(ReleasingWatchSupportTicket::class).collect { changeset -> if (changeset.removed || changeset.readModel == null) return@collect
val ticket = store.readModels.release(changeset.readModel!!) println("${changeset.modelKey}: ${ticket.requesterName}") }}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.readModels.ReadModel;
import io.cratis.chronicle.java.ReadModelsJavaBridge;import kotlinx.coroutines.Job;
@ReadModelclass ReleasingWatchSupportTicket { private String id = ""; private String requesterName = "";
public String getId() { return id; } public void setId(String id) { this.id = id; }
public String getRequesterName() { return requesterName; } public void setRequesterName(String requesterName) { this.requesterName = requesterName; }}
class ReadModelsReleasingPiiWatch { // watch() is the one built-in read that does not release PII automatically - release each // change yourself as it arrives. Job watchTickets(EventStore store) { return ReadModelsJavaBridge.watch(store.getReadModels(), ReleasingWatchSupportTicket.class, changeset -> { if (changeset.getRemoved() || changeset.getReadModel() == null) { return; }
ReleasingWatchSupportTicket ticket = ReadModelsJavaBridge.release(store.getReadModels(), changeset.getReadModel()); System.out.println(changeset.getModelKey() + ": " + ticket.getRequesterName()); }); }}Elixir does not support this workflow yet.import { IEventStore } from '@cratis/chronicle';
class ReleasingPiiSupportTicketWatcher { constructor(private readonly store: IEventStore) {}
async start(): Promise<void> { for await (const changeset of this.store.readModels.watch(ReleasingPiiSupportTicket)) { if (changeset.removed) { continue; }
const ticket = await this.store.readModels.release(ReleasingPiiSupportTicket, changeset.readModel); console.log(`${changeset.key}: ${ticket.requesterName}`); } }}Every other read on IReadModels releases before returning to you. Watch is the exception, because it’s a live feed rather than one completed read — release each change yourself as it arrives, the same way you’d release any other instance.
How the subject is resolved
Section titled “How the subject is resolved”Release looks at the instance you hand it to work out whose encryption key to use, in this order:
| Priority | Mechanism |
|---|---|
| 1 | A property decorated with [Subject] |
| 2 | A constructor parameter decorated with [Subject] (record shorthand) |
| 3 | A property named Id, case-insensitive |
The first match with a value wins. If an attributed property is null, empty or Subject.NotSet, Release continues to the Id fallback. This lets older rows and partially populated instances remain releasable while a newly projected [Subject] property is introduced.
If none of the three resolve to a value, Release returns the instance unchanged and logs a warning when the model has PII metadata. If the read model has no PII-annotated properties, it returns the instance unchanged without a warning because there is nothing to release.
Add [Subject] when the identity that manual Release should use differs from the read model’s own key, exactly as you would on a command or event property to control which identity an append is encrypted under. When the two already match — a person’s own profile, keyed by their own identity — the Id fallback handles it and no attribute is needed.
This attribute affects only subject discovery from the object passed to Release; it does not assign ownership to a managed projection document. Chronicle’s projection pipeline derives ownership from the event that supplied each PII value and persists that information in its reserved __subject and __subjects fields. An explicit subject supplied while appending an event therefore controls the values originating from that event, regardless of whether the read model exposes a Subject property.
When release can’t recover a value
Section titled “When release can’t recover a value”A property that can’t be decrypted — its encryption key was deleted, or it was encrypted under a different subject entirely — degrades on its own; the rest of the instance still comes back intact. What a caller sees for each of those cases is covered in Read models and PII.
If the release call itself can’t complete — a schema mismatch, for instance — Chronicle logs the failure and returns the instance exactly as you passed it in, still encrypted. If a value you expect to read back stays ciphertext after calling Release, check the application logs for that failure before assuming the data is unrecoverable.
Related topics
Section titled “Related topics”- Read models and PII - How Chronicle encrypts and decrypts PII automatically, and GDPR erasure
- The PII attribute - Marking event and read model properties as PII
- Watching Read Models - Subscribing to read model changes
- Getting a Single Instance - The strongly consistent read path that releases for you