Skip to content

Closing streams

Some facts end a story. An invoice is issued, a month is signed off, a customer is archived. After that, nothing should be appended to it, and a rule in your command code is not enough: two requests can race, and a bug in one client can still append.

Closing a stream moves that rule into the kernel. Once a scope is closed, every append that falls inside it is rejected with a constraint violation of type StreamClosed, no matter which client sends it.

A closure is a record in the kernel’s constraint storage. It is not an event, and it does not change the events already in the log. Every append is checked against the closures of its event sequence and namespace before it is committed.

There are two ways to create a closure:

WayWritten byReopened by
ManualYour code calls CompleteStreamNothing in the normal flow. Only the admin repair path
Event-drivenThe kernel, when it appends an event type declared with [ClosesStream]An event type you name in ReopenedBy

Pick the event-driven form when a domain fact ends the scope. The closing fact and the closure are written in the same step, and the closure shows up in the log as the fact that caused it. Pick the manual form for ad-hoc closes that no domain event represents.

A scope is any non-empty combination of four dimensions. A dimension you leave unset is not constrained:

DimensionMatches
Event source idOne event source, for example one invoice
Event source typeEvery event source of one type
Event stream typeA kind of stream, for example monthly-report
Event stream idA stream within a type, for example 2026-04

An append is refused when every dimension set in a closure equals the append’s value. Closing the stream monthly-report / 2026-04 of event source report-7 refuses only appends that carry all three values. Another report that uses the same stream type and id is unaffected.

Some examples:

ScopeRefuses
Event source report-7, stream type monthly-report, stream id 2026-04That one report’s April stream
Event source report-7Every stream of report-7, including its default stream
Stream type monthly-report, stream id 2026-04The April stream of every event source
Event source type ReportEvery event source of that type

EventSourceId.Unspecified, EventSourceType.Unspecified and an empty stream type or stream id count as unset. EventStreamType.All and EventStreamId.Default are real values: the scope event source report-7 + All + Default closes exactly the default stream of report-7.

Scoped closing shows how to close each combination from code.

The two-argument call closes one stream type and stream id pair across every event source:

using Cratis.Chronicle.EventSequences;
public class ClosingStreamsInvoiceCloser(IEventLog eventLog)
{
public async Task CloseInvoiceStream(EventStreamId invoiceStreamId)
{
var result = await eventLog.CompleteStream(new EventStreamType("invoices"), invoiceStreamId);
result.Switch(
sequenceNumber => Console.WriteLine($"Stream closed at sequence number {sequenceNumber}"),
error => Console.WriteLine($"Failed to close stream: {error}"));
}
}

View C# snippet source on GitHub

An empty stream type means All and an empty stream id means Default, so ("", "") is the default stream and returns DefaultStreamCannotBeCompleted. The call returns Result<EventSequenceNumber, CompleteStreamError>. On success the number is the tail of the event sequence at the moment of closing.

To close anything narrower or broader than “one pair for every event source”, pass a ClosedStreamScope to CompleteStream(scope, expectedTailSequenceNumber).

The optional expectedTailSequenceNumber makes the close race-free. The kernel compares it with the sequence number of the last event inside the scope and closes in the same step, so you close only if nothing landed after the last fact you know about. Scoped closing shows the pattern.

ErrorMeaning
AlreadyCompletedA manual closure already covers the scope. Nothing was written
DefaultStreamCannotBeCompletedThe scope would close the default stream on its own, see above
EmptyScopeNo dimension is set
ExpectedTailMismatchThe scope’s tail differs from expectedTailSequenceNumber. Nothing was written

A scope is covered when an existing closure’s dimensions are a subset of the scope’s dimensions and their values match. A narrower scope than an existing manual closure is therefore AlreadyCompleted. A broader one is not covered and creates a new closure.

Coverage by an event-driven closure alone does not return AlreadyCompleted. The call succeeds and writes a manual closure next to it. Manual closures are final, so a later ReopenedBy event cannot undo what an operator decided explicitly.

Retrying a close is safe: a second call returns AlreadyCompleted before the expected-tail check runs.

An append inside a closed scope is rejected with a StreamClosed violation. The violation details name the owner (ClosedBy, empty for a manual closure) and the sequence number at which the scope closed (ClosedAtSequenceNumber):

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
using Cratis.Chronicle.EventSequences;
using System.Linq;
[EventType]
public record ClosingStreamsInvoiceLineAdded(string Description, decimal Amount);
public class ClosingStreamsInvoiceLineAppender(IEventLog eventLog)
{
public async Task<bool> TryAppendLine(EventSourceId invoiceId)
{
var appendResult = await eventLog.Append(
invoiceId,
new ClosingStreamsInvoiceLineAdded("Consulting", 500m),
new EventStreamType("invoices"),
new EventStreamId("invoice-42"));
if (!appendResult.IsSuccess)
{
var violation = appendResult.ConstraintViolations
.FirstOrDefault(v => v.ConstraintType == ConstraintType.StreamClosed);
return violation is null;
}
return true;
}
}

View C# snippet source on GitHub

The check is active for every event sequence without configuration.

Closing does not touch what is already stored or how it is read:

  • Reads, observers, projections, reactors and replays work as before. A closed stream replays like any other.
  • Events already in the stream stay in the log.
  • A redacted closing event keeps its closure.

Closing only refuses new appends. The closing event itself is accepted.

  • No interleaving. The kernel writes an event-driven closure in the same turn as the durable append. No other append to that event sequence can land between the closing event and its closure. Unique constraints rely on the same mechanism.
  • Not crash-atomic. If the kernel stops after it wrote the event and before it wrote the closure, the scope stays open until the closure is rebuilt.
  • Reindex rebuilds event-driven closures. A constraint reindex that names a closing constraint rebuilds its closures from the events in the log. Manual closures are never touched. Registering or changing a declaration does not start a reindex and closes nothing in history. Removing a declaration leaves its closures in force.
  • Redacted property-sourced events. When a closing or reopening event takes its stream id from a property and that event has been redacted, the rebuild cannot recover the scope. It leaves that constraint’s closures untouched, logs a warning with the constraint and event position, and continues with the other constraints. Closures whose dimensions come from the event context are unaffected.
  • Event type generations. A property-sourced scope is read from the generation the event was appended with. If a new generation renames the property, update the declaration, otherwise closing and reopening fail for events of that generation.
  • Kernel version. Scoped closing and closing events use new kernel calls. A client that uses them needs a kernel that has them. An older kernel could otherwise ignore the extra dimensions and close too broadly.