Skip to content

Scoped closing

The two-argument CompleteStream(streamType, streamId) closes a stream for every event source that uses that pair. That forces you to make stream ids globally unique just so you can close one. A ClosedStreamScope lets you name what you mean instead. For the scope model and its rules, see Closing streams.

Set the event source id together with the stream type and stream id. Only that event source’s stream is refused. Other reports that use monthly-report / 2026-04 stay open:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
public class ScopedClosingReportCloser(IEventLog eventLog)
{
public async Task CloseApprovedReport(EventSourceId reportId)
{
// Close one report's "2026-04" stream. Other reports that use the same
// stream type and stream id stay open.
var scope = new ClosedStreamScope(
EventSourceId: reportId,
EventStreamType: new EventStreamType("monthly-report"),
EventStreamId: new EventStreamId("2026-04"));
var result = await eventLog.CompleteStream(scope);
result.Switch(
sequenceNumber => Console.WriteLine($"Closed at sequence number {sequenceNumber}"),
error => Console.WriteLine($"Not closed: {error}"));
}
}

View C# snippet source on GitHub

The scope has three dimensions set, so an append is refused only when it carries all three values.

ClosedStreamScope.ForEventSource sets only the event source id. Every stream of that event source is refused, including its default stream. This is how you archive an entity whose events use no stream id at all:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
public class ScopedClosingCustomerArchiver(IEventLog eventLog)
{
public async Task ArchiveCustomer(EventSourceId customerId)
{
// Every stream of this event source is closed, including its default stream.
var result = await eventLog.CompleteStream(ClosedStreamScope.ForEventSource(customerId));
result.Switch(
sequenceNumber => Console.WriteLine($"Customer archived at sequence number {sequenceNumber}"),
error => Console.WriteLine($"Not archived: {error}"));
}
}

View C# snippet source on GitHub

ClosedStreamScope.ForStream is the matching helper for the stream type and stream id pair, and it is what the two-argument overload uses.

Appending the last fact and then closing are two calls. Another append can land between them. Pass the sequence number of the last fact you know about as expectedTailSequenceNumber. The kernel compares it with the tail of the scope, the sequence number of the last event inside the scope, and closes in the same step. If another event landed, the call returns ExpectedTailMismatch and writes nothing:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
[EventType]
public record ScopedClosingReportApproved(string Approver);
public class ScopedClosingApprover(IEventLog eventLog)
{
public async Task<bool> ApproveAndClose(EventSourceId reportId)
{
var scope = new ClosedStreamScope(
EventSourceId: reportId,
EventStreamType: new EventStreamType("monthly-report"),
EventStreamId: new EventStreamId("2026-04"));
var approved = await eventLog.Append(
reportId,
new ScopedClosingReportApproved("alice"),
new EventStreamType("monthly-report"),
new EventStreamId("2026-04"));
if (!approved.IsSuccess) return false;
// Close only if nothing has landed in this scope after the approval.
var result = await eventLog.CompleteStream(scope, expectedTailSequenceNumber: approved.SequenceNumber);
var closed = false;
result.Switch(
_ => closed = true,
error => closed = error == CompleteStreamError.AlreadyCompleted);
return closed;
}
}

View C# snippet source on GitHub

An empty scope has the tail EventSequenceNumber.Unavailable. The scope’s tail only counts events inside the scope, so events appended elsewhere in the sequence do not cause a mismatch.

A retry is safe. The AlreadyCompleted check runs first, so repeating a close that succeeded returns AlreadyCompleted instead of a tail mismatch. The example above treats it as success.

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
public class ScopedClosingErrorHandling(IEventLog eventLog)
{
public async Task<string> Close(ClosedStreamScope scope)
{
var message = string.Empty;
var result = await eventLog.CompleteStream(scope);
result.Switch(
sequenceNumber => message = $"Closed at {sequenceNumber}",
error => message = error switch
{
CompleteStreamError.AlreadyCompleted => "Already closed by an earlier manual close",
CompleteStreamError.DefaultStreamCannotBeCompleted => "The default stream cannot be closed on its own",
CompleteStreamError.EmptyScope => "The scope names no dimension",
CompleteStreamError.ExpectedTailMismatch => "Something landed in the scope since you last looked",
_ => $"Unexpected error {error}"
});
return message;
}
}

View C# snippet source on GitHub

ErrorWhat to do
AlreadyCompletedA manual closure covers the scope. Usually a success from your point of view
DefaultStreamCannotBeCompletedAdd an event source id or another specific dimension
EmptyScopeThe scope has no dimension set. This is a bug in the caller
ExpectedTailMismatchRead the stream again and decide whether to close

Event-driven closures alone do not produce AlreadyCompleted. The call succeeds and writes a manual closure as well, which a ReopenedBy event can no longer undo.

Closing is final for manual closures. If you closed the wrong scope, see Inspecting and repairing.