---
title: Scoped closing
editUrl: https://github.com/Cratis/Chronicle/edit/main/Documentation/closing-streams/scoped-closing.mdx
description: Close one event source's stream, every stream of an event source, or any other combination of dimensions, and close only when nothing has landed since your last fact.
---

import { Tabs, TabItem } from '@astrojs/starlight/components';


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](/chronicle/closing-streams/).

## Close one event source's stream

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:

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/closing-streams/scoped-closing/close-source-stream.md)

</TabItem>
</Tabs>

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

## Close every stream of an event source

`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:

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/closing-streams/scoped-closing/close-source.md)

</TabItem>
</Tabs>

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

## Close only if nothing landed

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:

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/closing-streams/scoped-closing/close-expected-tail.md)

</TabItem>
</Tabs>

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.

:::tip[Prefer a closing event when a domain fact ends the scope]
If the last fact itself should end the scope, declare it as a [closing event](/chronicle/closing-streams/closing-events/). The closure is then written in the same step as the append, and there is no gap to guard.
:::

## Handle the errors

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/closing-streams/scoped-closing/handle-errors.md)

</TabItem>
</Tabs>

| Error | What to do |
| --- | --- |
| `AlreadyCompleted` | A manual closure covers the scope. Usually a success from your point of view |
| `DefaultStreamCannotBeCompleted` | Add an event source id or another specific dimension |
| `EmptyScope` | The scope has no dimension set. This is a bug in the caller |
| `ExpectedTailMismatch` | Read 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](/chronicle/closing-streams/inspecting-and-repairing/).
