Skip to content

ARCCHR0003: Reactor must not reach the default event log

A reactor produces side-effect events by returning them from its handler method — Chronicle appends what a handler returns. Reaching the default event log yourself performs the same write, but outside the side-effect pipeline.

This rule fires on every way of reaching it:

ShapeWhat it looks like
Injected event logMyReactor(IEventLog eventLog) : IReactor
Event log through the storeeventStore.EventLog.Append(...)
Default log named explicitlyeventStore.GetEventSequence(EventSequenceId.Log).Append(...)
Enlisted in a unit of workeventStore.EventLog.Transactional.Append(...)
Reached with a null-conditionaleventStore?.EventLog.Append(...)

Transactional hands back the same sequence enlisted in a unit of work, so the write is identical — the chain is one member longer. Every Append* overload counts, AppendMany included, and ?. anywhere in the chain changes nothing.

Warning

[Reactor]
public class IncomingInvitationReactor(IEventStore eventStore) : IReactor
{
[OnceOnly]
public Task On(UserInvited @event, EventContext context) =>
// ARCCHR0003: appends to the sequence the return type already targets
eventStore.EventLog.Append(context.EventSourceId, new InvitationIssued(@event.Email));
}
[Reactor]
public class IncomingInvitationReactor : IReactor
{
[OnceOnly]
public InvitationIssued On(UserInvited @event) => new(@event.Email);
}

Return a single event, an IEnumerable<object>, or EventForEventSourceId wrappers when the events belong to a different event source. To trigger work in another slice, inject ICommandPipeline and execute a command.

Injecting IEventStore is not itself a violation. The rule reports the append, not the dependency — a reactor that injects the store to read from it, to reach IReadModels, or to route to a different sequence is left alone.

Routing to another sequence is the case that matters most:

[Reactor]
public class AcceptanceOutbox(IEventStore eventStore) : IReactor
{
[OnceOnly]
public Task On(InvitationAccepted @event, EventContext context) =>
eventStore.GetEventSequence(EventSequenceId.Outbox)
.Append(context.EventSourceId, @event);
}

A returned side-effect event is always appended to the default event log — neither a bare event nor an EventForEventSourceId carries an EventSequenceId — so the outbox is not expressible as a return value and the store is the only way to reach it. The rule stays silent whenever GetEventSequence names anything other than the default log, including a sequence resolved at runtime.

Another event store is the same kind of exception. A returned event goes to this reactor’s own store, in this namespace. A store the reactor obtains at runtime targets a different one, so the rule’s advice would send the event somewhere else entirely:

[Reactor]
public class AuthorReplicator(IChronicleClient client) : IReactor
{
[OnceOnly]
public async Task On(AuthorRegistered @event, EventContext context)
{
var other = await client.GetEventStore("Reporting", "tenant-x");
await other.EventLog.Append(context.EventSourceId, @event);
}
}

The rule reports an append only through the event store the reactor holds — a constructor parameter, a field, or a property. Anything else is treated as a store the reactor went and found.

The analyzer reads the call in front of it. It does not follow values, so an append the rule would otherwise report goes unreported whenever the thing being appended to arrives from somewhere else in the method:

ShapeWhy it is missed
var log = eventStore.EventLog; log.Append(...)The sequence is held in a local.
var store = eventStore; store.EventLog.Append(...)The store is held in a local, and a local can hold another store as readily as this one.
GetEventSequence(sequenceId) where sequenceId is a parameter or a fieldThe sequence is only known at run time; it may or may not be the default log.
GetEventSequence(flag ? EventSequenceId.Log : EventSequenceId.Outbox)Same — the analyzer cannot pick a branch.
An append in a base class that does not itself implement IReactorThe rule decides from the type the append sits in, and that type is not a reactor.
An append in a helper type the reactor delegates toSame — the helper is not a reactor.

None of this is a suppression mechanism to reach for. It is the honest boundary of a syntactic rule: it catches the shape you write by accident, not the one you write to get around it. Every one of these still costs you ReactorScenario<T>.Produced at spec time, which is where the absence really bites.

ARCCHR0003 previously matched only an injected IEventLog. It now also matches an append through an injected IEventStore, so a reactor with that shape starts warning on the first build after you upgrade — and if you build with warnings as errors, that is a build break rather than a warning.

The migration is to return the event. Change the handler from Task to the event type and drop the store:

// Before — warns, and ReactorScenario<T>.Produced sees nothing
public Task On(UserInvited @event, EventContext context) =>
eventStore.EventLog.Append(context.EventSourceId, new InvitationIssued(@event.Email));
// After — Chronicle appends the returned event, and ShouldHaveProduced<InvitationIssued>() works
public InvitationIssued On(UserInvited @event) => new(@event.Email);

If the append targets a different event source, return EventForEventSourceId(id, @event) — or an IEnumerable<object> mixing bare events and wrappers. If it targets a different sequence or a different store, the rule does not fire in the first place.

To get the build green while you work through it, suppress at the narrowest scope that unblocks you:

#pragma warning disable ARCCHR0003
eventStore.EventLog.Append(context.EventSourceId, new InvitationIssued(@event.Email));
#pragma warning restore ARCCHR0003

Or turn it off for a folder or the whole project from .editorconfig:

[*.cs]
dotnet_diagnostic.ARCCHR0003.severity = none

suggestion and silent are the middle settings — the rule keeps reporting in the IDE without failing the build.

The two shapes write the same event to the same sequence, so the rule’s reason applies to both: replay and side-effect semantics stay Chronicle’s concern only while the append goes through the return type.

The cost shows up later, in the testing surface. ReactorScenario<T>.Produced is the side effects the reactor returned from its handler methods — a handler that returns bare Task and appends through an injected store produces nothing by that definition, so ShouldHaveProduced<T>() throws and the sanctioned assertion surface is simply unavailable. Nothing at authoring time tells you that you have left the contract; this rule does.

  • ARCCHR0005 — Chronicle is used but not wired up