Skip to content

Closing events

A manual CompleteStream call writes nothing to the log, cannot be rebuilt from events and is a second step after the final append. A closing event removes all three problems. You declare that appending a particular event type closes a scope. The kernel then writes the event and the closure together, and the closure can be rebuilt from the log.

Use it when a domain fact ends something: ReportDelivered, BooksClosed, VersionSuperseded.

With the attribute, the declaration sits on the event:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
[ClosesStream(Dimensions = ClosedStreamDimensions.EventSourceId | ClosedStreamDimensions.EventStreamType)]
public record ClosingEventsReportDelivered(string Recipient);

View C# snippet source on GitHub

With the builder, the declaration is a constraint class:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ClosingEventsVersionSuperseded(string Version);
[EventType]
public record ClosingEventsVersionReinstated(string Version);
public class ClosingEventsVersionClosing : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.ClosesStreamOn<ClosingEventsVersionSuperseded>(
closes => closes
.PerEventSourceId()
.EventStreamIdFrom(@event => @event.Version)
.ReopenedBy<ClosingEventsVersionReinstated>(),
name: "close-superseded-version");
}

View C# snippet source on GitHub

Both forms compile to the same constraint. Details of each style are in the model-bound and declarative reference pages.

By default the closed scope is the appended event’s event source id, stream type and stream id, taken from the append. To close something else, choose dimensions. The attribute takes a Dimensions value and the builder has PerEventSourceId(), PerEventSourceType(), PerEventStreamType() and PerEventStreamId().

To take the stream id from the event’s payload instead of the append, use EventStreamIdFrom (attribute) or EventStreamIdFrom(@event => @event.Period) (builder). That implies the stream id dimension. This is useful when the closing fact names the period it closes while it is appended somewhere else:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
[ClosesStream(EventStreamIdFrom = nameof(Period), ReopenedBy = [typeof(ClosingEventsBooksReopened)])]
public record ClosingEventsBooksClosed(string Period, decimal ClosingBalance);
[EventType]
public record ClosingEventsBooksReopened(string Period, string Reason);

View C# snippet source on GitHub

A scope resolving to only default-stream values is refused for both closing and reopening events; it cannot close the default stream for every event source.

A property-sourced value must be present. If it is missing, null or empty, the kernel refuses the append with a violation naming the property rather than closing something broader. Make the property non-nullable. The analyzer CHR0012 warns on nullable event properties.

The closing event is accepted. Every later event inside the closed scope is refused with StreamClosed, and events elsewhere are not affected:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ClosingEventsEntryPosted(string Description);
public class ClosingEventsAfterClose(IEventLog eventLog)
{
public async Task<bool> Run(EventSourceId ledgerId)
{
await eventLog.Append(
ledgerId,
new ClosingEventsBooksClosed("2026-04", 1200m),
new EventStreamType("ledger"),
new EventStreamId("2026-04"));
var late = await eventLog.Append(
ledgerId,
new ClosingEventsEntryPosted("Late invoice"),
new EventStreamType("ledger"),
new EventStreamId("2026-04"));
// The late entry is refused; the next period is unaffected.
return late.ConstraintViolations.Any(violation => violation.ConstraintType == ConstraintType.StreamClosed);
}
}

View C# snippet source on GitHub

Inside one AppendMany, events after the closing event that fall inside the scope are refused, and the batch result names them. A batch that closes, reopens and then appends a covered event succeeds for the covered event, because the batch is evaluated in order.

A closing event cannot be appended into a scope that is already closed. The closed-stream check applies to it like to any other event.

Name the event types that lift the closure with ReopenedBy (attribute) or ReopenedBy<T>() (builder). The reopening fact records who reopened and why in the log, which is the reason to prefer it over a kernel call.

A ReopenedBy event:

  • removes only the closure owned by its own constraint, for the exact scope it resolves to;
  • is accepted inside the scope it reopens, because it is exempt from its own constraint’s closure;
  • is not exempt from a manual closure or from another constraint’s closure. If an operator also closed the scope with CompleteStream, the reopening event is refused;
  • must carry the same EventStreamIdFrom property as the closing event. The client throws PropertyDoesNotExistOnEventType when it discovers the constraint if the property is missing.

Each closure belongs to its constraint. The constraint name defaults to the event type name. Set Name on the attribute or pass name to the builder to choose another. Two declarations that share a name merge into one constraint, with their closing and reopening event types combined. They must use the same dimensions and stream id property, otherwise the client throws ConflictingClosesStreamDeclarations. The empty name is reserved for manual closures.

Set EventSequences on the attribute to restrict a declaration to specific event sequences. Empty means all.

Registering or changing a declaration closes nothing in history. Only events appended afterwards close scopes. A reindex that names the constraint rebuilds its closures from the log, see Closing streams for the guarantees, the crash window and the redaction limit. A closing-constraint rebuild must not run while appends to the sequence are in flight.

EventScenario supports closing and reopening events. See EventScenario.