Skip to content

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

View C# snippet source on GitHub

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.

using Cratis.Chronicle;
public class ReleasingPiiSupportTicketService(IEventStore eventStore)
{
public Task<ReleasingPiiSupportTicket> Release(ReleasingPiiSupportTicket ticket) =>
eventStore.ReadModels.Release(ticket);
}

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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.

Release looks at the instance you hand it to work out whose encryption key to use, in this order:

PriorityMechanism
1A property decorated with [Subject]
2A constructor parameter decorated with [Subject] (record shorthand)
3A 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, Release returns the instance unchanged — there is nothing to release, so it doesn’t call the kernel.
  • If it carries only subject-scoped values, Release returns 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 — Release still 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.

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.