Releasing PII and encrypted values
Most of the time you never think about this. GetInstanceById, GetInstances, and GetSnapshotsById on IReadModels already hand you both [PII] and [Encrypted] values 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, and Security for [Encrypted].
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. Release decrypts whichever of [PII] and [Encrypted] the read model actually carries - both, either, or neither - there is nothing to configure.
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';import { field } from '@cratis/fundamentals';
@eventType()class ReleasingPiiSupportTicketOpened { @field(String) readonly customerId: string; @field(String) readonly requesterName: string;
constructor(customerId: string, requesterName: string) { this.customerId = customerId; this.requesterName = requesterName; }}
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, and for a projection as well, since Chronicle encrypts from the read model’s own schema and does not carry an event property’s PII marker over to the property it is mapped to. 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.
A subject is what a subject-scoped value is keyed by — [PII], and [Encrypted] with the default EncryptionScope.Subject. A namespace- or global-scoped [Encrypted] value (see Scope) needs no subject at all, so what happens when none of the three resolve depends on what the read model actually carries:
- If the read model carries no
[PII]/[Encrypted]metadata at all,Releasereturns the instance unchanged — there is nothing to release, so it doesn’t call the kernel. - If it carries only subject-scoped values,
Releasereturns the instance unchanged and logs a warning — there is no key identity to release them under, the same as a computed[PII]value that was never round-tripped through encryption. - If it carries a namespace- or global-scoped
[Encrypted]value — with or without subject-scoped values alongside it —Releasestill calls the kernel, because that value’s key doesn’t depend on a subject. Any subject-scoped value alongside it, with no subject to release against, degrades exactly as described below in “When release can’t recover a value”.
This is also why a read model carrying only a namespace- or global-scoped secret is ordinarily written with no Id or [Subject] property at all — there is no per-document identity for that kind of value to be scoped by in the first place.
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. The property comes back as the empty value of its type: 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. Whether an erased value should be distinguishable from a stored 0, false or empty value is an open decision in issue 4620. 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
- Encrypting operational secrets - Marking a value
[Encrypted]instead of[PII] - Watching Read Models - Subscribing to read model changes
- Getting a Single Instance - The read path that releases for you