Skip to content

CHR0046: Passive read model redirects the document key away from the event's own stream

A read model is passive — declared with [Passive], or with .Passive() on the projection builder — and its projection redirects the root document key with UsingKey, UsingKeyFromContext, UsingCompositeKey, UsingConstantKey, or model-bound [FromEvent(key:)].

A passive read model has no sink. Nothing writes its documents ahead of a read, so the kernel answers a read by replaying the event sequence with the cursor constrained to eventSourceId = ReadModelKey — exactly one physical event stream. A top-level key redirection is routing metadata for a materialized write; it is not a lookup index over the event log, and it cannot make a replay find events that live on another stream.

So if the events were appended to user-1 and the projection re-keys the document by Hash, a passive read by Hash replays the Hash stream, reaches no events, and returns a default-initialized model. Registration succeeds, and a ReadModelScenario<T> that appends and reads in one namespace still passes.

Shapes that are not reported:

  • The same redirection on a read model that keeps its sink — an observer materializes the redirected document, and the read is answered from storage.
  • UsingKeyFromContext(c => c.EventSourceId), which restates the identity the replay already walks.
  • A child or parent key — UsingParentKey, or UsingKey inside Children/AddChild — which identifies a child inside a document the replay does reach.
  • A reducer-backed passive read model, which declares no projection and therefore no document key.

Warning

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections;
using Cratis.Chronicle.ReadModels;
[EventType]
public record Chr0046UserSignedUp(string Hash, string Region);
// Warning CHR0046: 'UsingKey' keys the 'Chr0046UserByHash' document by 'Hash', but
// 'Chr0046UserByHash' is passive. The events were appended to the user's own stream, so a read
// by hash replays the hash stream, reaches nothing, and hands back a default-initialized model.
[Passive]
public record Chr0046UserByHash(
[Key] string Id,
string Region);
public class Chr0046UserByHashProjection : IProjectionFor<Chr0046UserByHash>
{
public void Define(IProjectionBuilderFor<Chr0046UserByHash> builder) => builder
.From<Chr0046UserSignedUp>(_ => _
.UsingKey(e => e.Hash)
.Set(m => m.Region).To(e => e.Region));
}
// The same redirection on a read model that keeps its sink is not reported: an observer
// materializes the redirected document, and the read is answered from storage.
public record Chr0046UserByHashMaterialized(
[Key] string Id,
string Region);
public class Chr0046UserByHashMaterializedProjection : IProjectionFor<Chr0046UserByHashMaterialized>
{
public void Define(IProjectionBuilderFor<Chr0046UserByHashMaterialized> builder) => builder
.From<Chr0046UserSignedUp>(_ => _
.UsingKey(e => e.Hash)
.Set(m => m.Region).To(e => e.Region));
}

Nothing fails. The projection registers, the read returns an object rather than throwing or returning “not found”, and the only symptom is that every field holds its default. A developer reading that model back sees an empty region and an empty name, and the natural first suspicion is the projection’s mappings — which are correct. The declaration that actually caused it, [Passive], sits on the read model and looks unrelated to the key.

The documentation now states the limitation where a reader looking up single-instance reads will meet it: replay walks one event source, so it cannot reconstruct state built from events belonging to a different key. That is an explanation after the fact. This rule is the part that catches it before the model ships.

It is a warning rather than an error for its first release. A consumer may knowingly accept a passive read model that only ever answers for the redirected stream — for instance where the redirected key is the event source id for every append in practice — and a new error would break those builds outright under warnings-as-errors.

The rule under-reports rather than guessing. UsingKeyFromContext(c => c.Subject) is left alone even though a subject can differ from the event source id, on the same reasoning recorded for CHR0043: source declarations cannot prove what append metadata put in the context.