Epsitec.Briefcases.Sdk 3.3.3.2630

Prefix Reserved
dotnet add package Epsitec.Briefcases.Sdk --version 3.3.3.2630
                    
NuGet\Install-Package Epsitec.Briefcases.Sdk -Version 3.3.3.2630
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Epsitec.Briefcases.Sdk" Version="3.3.3.2630" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Epsitec.Briefcases.Sdk" Version="3.3.3.2630" />
                    
Directory.Packages.props
<PackageReference Include="Epsitec.Briefcases.Sdk" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Epsitec.Briefcases.Sdk --version 3.3.3.2630
                    
#r "nuget: Epsitec.Briefcases.Sdk, 3.3.3.2630"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Epsitec.Briefcases.Sdk@3.3.3.2630
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Epsitec.Briefcases.Sdk&version=3.3.3.2630
                    
Install as a Cake Addin
#tool nuget:?package=Epsitec.Briefcases.Sdk&version=3.3.3.2630
                    
Install as a Cake Tool

Epsitec.Briefcases.Sdk

Client SDK for Crésus Briefcases. Built around a session that works in two modes. Connected: the server is the authoritative store; the client rehydrates the workspace tree from the server ledger at the start of a session and, with the default transient in-memory store, discards everything on exit. Local: pointed at a file-backed store root (LocalStoreRootPath), the session creates, writes, and reads briefcases with no server at all, and the store it produces is the very layout the briefcases CLI reads and writes — an application and the CLI sharing one root act as one client (see Working offline below). In either mode the SDK never materializes a working directory on disk — that is the CLI's job.

Usage — connect, discover, open, write

var options = new BriefcasesSessionOptions (
    ServerUrl: "https://briefcases.example.com",
    UserId: userId,
    UserKeys: userKeys,
    AccessTokenProvider: () => app.GetAccessTokenAsync ());

await using var session = new BriefcasesSession (options);

await session.ConnectAsync ();

// Discover the briefcases you can reach, then resolve the ones you can open.
var known      = await session.GetKnownBriefcaseIdsAsync ();
var workspaces = await session.ListWorkspacesAsync (known);
var workspace  = await session.OpenWorkspaceAsync (workspaces[0]);

// Push into a chosen folder. The parent is required: use workspace.RootPath
// for the tree root, or a folder path returned by CreateFolderAsync.
var folder = await workspace.CreateFolderAsync (
    workspace.RootPath, "Receipts", tags: ["year:2026"]);

var path = await workspace.PushDocumentAsync (
    folder,
    new DocumentPayload (
        "coop-receipt-2026-06-12.jpg",
        [
            new StreamPayload ("image/jpeg", imageBytes),
            new StreamPayload ("x-edm/fulltext", ocrTextUtf8Bytes),
            new StreamPayload ("application/json", receiptJsonUtf8Bytes),
        ],
        Tags: ["client:42", "archived"]));

Tree-aware writes and tag queries

PushDocumentAsync and CreateFolderAsync both take a parent NodePath already present in the workspace view (workspace.RootPath qualifies). The new node lands at {parent}/n…. All argument validation (parent, file/folder name, streams, tags, sibling-name collision) is side-effect-free and completes before any blob is staged or any network call is made.

CreateFolderAsync is idempotent on the folder name: if a child folder of that name already exists, its path is returned unchanged. The returned path is immediately usable as a parent for further pushes or folders.

Editing existing nodes — update, rename, retag

Three additive mutations edit a node already in the tree without changing its path. Each refreshes the view and advances Generation on a real change, but is a no-op (nothing committed, Generation unchanged) when the requested state already matches:

// Replace a document's content (full-version replace): streams, name, and tags
// all come from the payload. A different FileName renames the node; different
// Tags replace the USER tags; an EMPTY Tags array CLEARS them. System `$`-tags
// are stripped from the caller's input and carried over automatically. Echo the
// current name and tags to edit content only. A fresh per-version node key is
// minted (this is not an access-key rotation).
await workspace.UpdateDocumentAsync (
    path,
    new DocumentPayload (
        "coop-receipt-2026-06-12.jpg",
        [new StreamPayload ("image/jpeg", editedImageBytes)],
        Tags: ["client:42", "archived"]));

// Rename a file or folder (metadata-only; content untouched).
await workspace.RenameAsync (path, "coop-receipt-final.jpg");

// Replace a node's entire USER tag set (metadata-only). There is no incremental
// add/remove: read the node's current Tags from the view, compute the full
// intended set, and pass it. An empty set clears all user tags. System `$`-tags
// are stripped from the caller's input and carried over automatically.
await workspace.SetTagsAsync (path, ["client:42", "reviewed"]);

// Mark a subtree your application owns as VIRTUAL: it stays fully readable
// through this API, but `restore` never writes it into a member's working
// directory and never downloads its content for materialization. Mark the
// root; virtuality cascades to its descendants. The mark survives every
// tag/content edit above, and only this call sets or clears it.
await workspace.SetVirtualAsync (folderPath, isVirtual: true);

Reindex vs refresh for an EDM consumer. All three commit a real Modified the reader's DiffAsync reports (never None). The etag (ContentHashes vs PreviousContentHashes) tells the two apart: UpdateDocumentAsync re-stages content, so the etag changes ⇒ reindex (OCR/embedding re-extraction); RenameAsync / SetTagsAsync leave the content untouched, so the etag is equal ⇒ a cheap metadata refresh, no re-extraction.

Editing single streams

A file node's content is one or more streams: the primary stream (streamId = 0) is the document itself, and auxiliary streams (streamId >= 1) hold whatever derived data your application authors beside it — extracted full text, a chunk index, a thumbnail. SetStreamAsync and RemoveStreamAsync edit exactly one auxiliary stream, leaving every other stream, the node key, the name, and the tags untouched. Only the edited stream's blobs are encrypted and uploaded, so refreshing a 50 KB text extraction beside a 200 MB scan costs 50 KB.

// Refresh the extracted-text stream after a reindex. The stream is added when
// the node does not carry that id yet, and replaced in place when it does.
await workspace.SetStreamAsync (
    path, streamId: 1,
    new StreamPayload ("x-edm/document", extractedTextBytes));

// Drop a derived index that is no longer maintained. Removing an id that is
// already absent is a no-op, so a cleanup pass is idempotent.
await workspace.RemoveStreamAsync (path, streamId: 2);

Stream ids are the only selector — media types may repeat across streams — and the caller owns its own id space. streamId = 0 is refused by both methods: the primary stream is the document, and replacing it is a new version through UpdateDocumentAsync.

Both leave the primary stream alone, so DiffAsync reports Modified with an unchanged etag: a consumer keyed off the primary content sees a metadata refresh, not a reindex. Read a stream back through IBriefcaseReader's stream open, addressing it by the same id.

Find nodes by tag through the FindByTag / FindByTagPrefix extension methods (one-off lookups); for several queries against one generation, call BuildTagIndex () once and reuse it.

var sub = await workspace.CreateFolderAsync (folder, "Q4");
await workspace.PushDocumentAsync (sub, document);

ImmutableArray<NodePath> tagged   = workspace.FindByTag ("client:42");
ImmutableArray<NodePath> byPrefix = workspace.FindByTagPrefix ("client:");

Discovery — reachable briefcases and their home servers

ListWorkspacesAsync and ListWorkspaceCardsAsync take the candidate BriefcaseIds to resolve — the server confirms membership per id and never enumerates a user's briefcases. GetKnownBriefcaseIdsAsync supplies those candidates:

var known      = await session.GetKnownBriefcaseIdsAsync ();
var workspaces = await session.ListWorkspacesAsync (known);        // members only
var cards      = await session.ListWorkspaceCardsAsync (known);    // + decrypted cards

By default the candidate set is what this client already holds locally. Point the session at a directory and GetKnownBriefcaseIdsAsync additionally returns the briefcases the directory catalog lists for the user — including ones this device has never opened — and each briefcase's server work is then routed to that briefcase's home server:

var options = new BriefcasesSessionOptions (
    ServerUrl: "https://home.example.com",       // this user's primary home server
    UserId: userId,
    UserKeys: userKeys,
    AccessTokenProvider: () => app.GetAccessTokenAsync ())
{
    DirectoryUrl                 = "https://directory.example.com",
    DirectoryAccessTokenProvider = () => app.GetDirectoryTokenAsync (),  // optional; falls back to AccessTokenProvider
    DirectoryCacheRootPath       = localCachePath,                       // optional; caches home URLs for a fast/offline start
};

With a directory configured, the session opens one connection per distinct home server and sends each briefcase's reads, writes, blobs, and leases to that briefcase's home. Discovery is availability-tiered: a warm home-URL cache keeps an already-started session working while the directory is briefly unreachable, and GetKnownBriefcaseIdsAsync degrades to the locally-known set rather than throwing. Without a DirectoryUrl the session is single-server and every operation targets ServerUrl — the pre-federation behaviour.

Creating a briefcase and inviting a user

A session is not limited to briefcases that already exist. CreateBriefcaseAsync commits the genesis transaction for a brand-new briefcase and returns it already opened, so it slots in exactly where OpenWorkspaceAsync would — the whole write surface above (folders, PushDocumentAsync, updates, RefreshAsync) applies unchanged:

await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();

// Returns the opened IWorkspace for the new briefcase (no List+Open round-trip).
var workspace = await session.CreateBriefcaseAsync (
    new CreateBriefcaseOptions (ProbationMinutes: 2880));   // 48 h successor window

var path = await workspace.PushDocumentAsync (workspace.RootPath, document);
//  workspace.Id is the fresh BriefcaseId, usable by Open/Reader/Watch.

InviteAsync is a method on the opened workspace (it reads that workspace's live ledger state and advances its Generation like any other write). The invitee's public keys are not derived here — the host supplies the UserPublicKeys, typically from the Identity package's discovery call BriefcasesIdentity.GetPublicKeysAsync, which replaces the manual profile.json exchange:

// inviteeKeys came from the host's discovery step (e.g. Identity.GetPublicKeysAsync).
Invitation invite = await workspace.InviteAsync (inviteeUid, inviteeKeys);

// …or restrict the grant to a subtree already present in workspace.Nodes:
Invitation partial = await workspace.InviteAsync (
    inviteeUid, inviteeKeys,
    new InviteOptions (FullAccess: false, Path: folder, Message: "Receipts only"));

// invite.Pin is produced ONCE and never persisted by the SDK — share it
// out-of-band (voice, secure messaging). invite.InviteId identifies the invitation.

Full-access invitations require the caller to be the briefcase owner; a non-owner attempt throws InvalidOperationException.

On the invitee's side (their own session, their own keys), AcceptInvitationAsync verifies the PIN and returns the BriefcaseId — exactly what OpenWorkspaceAsync / OpenReaderAsync / WatchAsync consume. Accept does not auto-open: the invitee decides when to open (which performs the download sync), mirroring the List → Open separation:

ImmutableArray<PendingInvitation> pending = await session.ListInvitationsAsync ();

BriefcaseId bid = await session.AcceptInvitationAsync (pending[0].InviteId, pin);

var workspace = await session.OpenWorkspaceAsync (bid);
await workspace.RefreshAsync ();   // pull the inviter's committed generations

A wrong PIN throws InvitationPinException, which exposes RemainingAttemptCount so the caller can re-prompt within the server's rate-limited budget. The inviter withdraws a pending invitation with CancelInvitationAsync; the invitee turns one down with DeclineInvitationAsync — both leave it absent from a later ListInvitationsAsync.

The invitee key authenticity is the caller's trust boundary: the SDK invites whatever UserPublicKeys it is handed. Binding those keys to the right person is the discovery step's job (Identity.GetPublicKeysAsync validates the ml-kem-768 profile and the keyset subject).

Working offline — local briefcases

A session pointed at a local store root works with no server at all: no ConnectAsync, no server URL, no token. The store it reads and writes is the same on-disk layout the briefcases CLI uses ({root}/{bid}/ledger|data), and both resolve the same per-briefcase replica identity from {root}/{bid}/replica.json — an application and the CLI sharing one root are one replica to the server: each holds the other's scope leases, and both read the same lease-cache files. Concurrent offline commits from the two processes serialize under a per-briefcase commit lock, so neither can lose the other's transaction.

var options = new BriefcasesSessionOptions (
    ServerUrl: null,                  // never connects; ConnectAsync would throw
    UserId: userId,
    UserKeys: userKeys,
    AccessTokenProvider: null)
{
    LocalStoreRootPath = @"C:\data\.briefcases",   // builds and owns the file store
};

await using var session = new BriefcasesSession (options);

// Create a briefcase entirely offline: genesis is committed to the local
// ledger, and this replica is seeded with the solo nomadic (offline-commit)
// right, so the returned workspace is immediately writable.
var workspace = await session.CreateLocalBriefcaseAsync ();

var folder = await workspace.CreateFolderAsync (workspace.RootPath, "Notes");
var path   = await workspace.PushDocumentAsync (folder, new DocumentPayload (
    "note.md", [new StreamPayload ("text/markdown", noteBytes)]));

// Read content back without a connection: blobs resolve from the local pool.
await using var reader = await session.OpenLocalReaderAsync (workspace.Id);
await using var stream = await reader.OpenStreamAsync (path, GenerationId.Latest);

CreateLocalBriefcaseAsync requires a durable identity: set LocalStoreRootPath, or inject a storage and set LeaseCacheRootPath explicitly — otherwise it throws InvalidOperationException before writing anything.

The offline write gate

Offline writability is a right, not a default. OpenLocalWorkspaceAsync enforces it per open:

  • LocalWritePolicy.RequireNomadicRight (the default) — writes require the locally cached nomadic right for this replica. The creator of a local briefcase holds it from creation; any other copy of the store (a fresh replica.json, hence a fresh identity) is read-only — a write throws OfflineWriteNotAuthorizedException, in agreement with what GetWritabilityAsync reports (OfflineWithoutNomadicRight). A replica earns the right ahead of time, online, by taking the nomadic lease (see Write coordination below).
  • LocalWritePolicy.Unrestricted — no lease gate; the explicit escape hatch for recovery flows (owner key work on a local store). Name it at the call site; never make it a default.
var local    = await session.OpenLocalWorkspaceAsync (bid);   // gate enforced
var recovery = await session.OpenLocalWorkspaceAsync (
    bid, LocalWritePolicy.Unrestricted);                      // recovery escape hatch

Readers resolve blobs local-first everywhere: a blob already in the local pool is never downloaded (connected sessions included), and on a never-connected session a blob missing locally throws LocalBlobUnavailableException for that node only — other nodes keep reading. Server-only operations (invitations, cards, membership, succession, watch, RefreshAsync) keep their connected-session requirement and throw InvalidOperationException on a session that never connected.

Going online later

An offline-created briefcase carries its whole ledger locally, and the server accepts a briefcase it has never seen: the first online commit against it detects the empty server and pushes the local chain — generations are preserved verbatim, nothing is renumbered. The most direct route is the CLI on the same store root:

briefcases sync --root C:\data\.briefcases --bid <bid>

Because the SDK and the CLI resolve the same replica identity from the shared root, the pushing process presents the briefcase's founding replica — the implicit holder of the offline-commit right — and the push is accepted; the briefcase's home server then reports it to the directory automatically.

Succession time capsules (data continuity if the owner disappears)

A briefcase owner can designate a member as successor: the briefcase access key is sealed for the successor's keys and additionally timelocked to a public randomness beacon (drand quicknet), so nobody — the server included — can open the escrow before 2 × ProbationMinutes from the seal. The successor gains access only after filing a claim, surviving the owner-vetoable probation window, and waiting out the timelock.

Owner side — designate, inspect, veto:

// Designate (also the manual re-seal: re-designating refreshes the timelock).
var outcome = await workspace.DesignateSuccessorAsync (successorUid);
Console.WriteLine ($"escrow timelocked until {outcome.EscrowUnlockTime:O}");

// Inspect: phase, claim deadlines, and the stored escrow's condition.
var status = await workspace.GetSuccessionStatusAsync ();

foreach (var s in status.Successors)
{
    // s.Phase: Designated / Pending / Accepted.
    // s.Escrow (owner only): Timelocked / Unusable / Absent (designation-only).
    // s.EscrowNeedsRefresh: re-seal recommended (stale or unusable).
    Console.WriteLine ($"{s.UserId}: {s.Phase}, escrow {s.Escrow}");
}

// Veto a pending claim (the successor stays designated and may re-request),
// or remove the successor entirely (drops the escrow):
await workspace.CancelSuccessionAsync (successorUid);
await workspace.RevokeSuccessorAsync (successorUid);

The escrow's timelock decays: it protects fully for one probation window past the owner's last (re-)seal. With AutoRefreshSuccessionEscrows (default true on BriefcasesSessionOptions), OpenWorkspaceAsync quietly re-seals stale or unusable escrows whenever the owner opens the workspace; deliberately escrow-less designations (Absent) are never touched. RefreshSuccessionEscrowsAsync runs the same pass explicitly.

Successor side — claim, wait out probation, accept and open:

await workspace.RequestSuccessionAsync ();   // probation starts (owner can veto)

// … after the probation window …
var accept = await workspace.AcceptSuccessionAsync ();

switch (accept.Result)
{
    case SuccessionAcceptResult.Opened:
        // Both capsule layers opened; THIS workspace now reads every node
        // (owner-equivalent read), e.g. to copy content into a new briefcase.
        break;

    case SuccessionAcceptResult.Locked:
        // Accept committed; the timelock round is not due yet. Retry after:
        Console.WriteLine ($"retry after {accept.UnlockTime:O}");
        break;

    case SuccessionAcceptResult.NoEscrow:        // ask the owner to (re-)provision
    case SuccessionAcceptResult.EscrowUnusable:  // idem (see accept.Detail)
    case SuccessionAcceptResult.RelaysUnreachable:  // durable; retry later
        break;
}

AcceptSuccessionAsync is idempotent: a Locked or RelaysUnreachable outcome is finished by calling it again — the claim itself is durable ledger state. Opening the timelock fetches the unlocking drand beacon over the public relays (verified locally against the pinned quicknet chain); inject an IDrandClient into the BriefcasesSession constructor to control that transport.

Workspace cards (member-readable listing metadata)

ListWorkspacesAsync returns opaque BriefcaseId values. To show a human-meaningful listing — workspace name, owner, an optional description, colour, icon and tags — without downloading each ledger, call ListWorkspaceCardsAsync. It returns one decrypted WorkspaceCardInfo per accessible briefcase that has a card and for which the session user holds a capsule; each is decrypted with the user's own private keys, with no ledger replay.

var known = await session.GetKnownBriefcaseIdsAsync ();
var cards = await session.ListWorkspaceCardsAsync (known);

foreach (var card in cards)
{
    Console.WriteLine ($"{card.BriefcaseId}: {card.WorkspaceName}");
    //  card.OwnerUserId is opaque — resolve the display name via auth.
    //  card.WorkspaceKind / WorkspaceDescription / WorkspaceColor / Tags are optional.
    //  card.WorkspaceIcon (+ WorkspaceIconMediaType) is a small icon, or empty.
}

Briefcases without a card, or for which the user has no capsule yet, are omitted from ListWorkspaceCardsAsync; cross-reference ListWorkspacesAsync to show those by bid. A card that is present but the user cannot decrypt (a stale capsule, a corrupt blob) is not dropped — it surfaces as a degraded WorkspaceCardInfo carrying only its BriefcaseId (empty WorkspaceName, so IsEmpty is true), so a single unreadable card never fails the whole listing. The card is a low-churn projection of an in-ledger anchor: a card that lags the ledger head is accepted by design, and the blind server never sees any card field in cleartext (the blob is AEAD-bound to its briefcase id under a per-card random key distributed only via per-member capsules).

Optional signature verification (defense in depth). WorkspaceCardInfo.SignatureVerified is tri-state: null (not checked), true (the owner's envelope signature verified), or false (it failed). It stays null unless you inject an IWorkspaceCardSignerKeyResolver into the session — the resolver returns the signer's public keys (resolved from the trusted auth directory by signer id + keyset hkid, which the server serves with each card), and the SDK verifies the envelope itself. Verification is best-effort and never fails the listing; the SDK takes no auth dependency, so the consumer that already holds the auth wiring supplies the resolver.

Reading: point-in-time read, generation diff, stream open

IWorkspace is the current tree plus add-only writes. To read a node as it stood at a past generation, to obtain what changed between two generations, or to read the decrypted bytes of one stream, open a read-only IBriefcaseReader:

await using var reader = await session.OpenReaderAsync (bid);

// Read a node (or subtree) at a generation. `at` of `Empty`/`Latest` ⇒ Head.
NodeView? node = await reader.ReadNodeAsync (
    NodePath.From ("/n1/n43"), at: GenerationId.From (1200), depth: NodeDepth.All);

// Diff the inclusive window [from, to], ordered by NodePath.
await foreach (var change in reader.DiffAsync (GenerationId.From (1), reader.Head))
{
    // change.Kind is Added / Modified / Removed (never None).
    // change.ContentHashes vs change.PreviousContentHashes distinguishes a real
    // reindex (content changed) from a metadata-only refresh (equal etags).
}

// Open the decrypted plaintext of one stream (default streamId 0 = primary).
await using var stream = await reader.OpenStreamAsync (
    NodePath.From ("/n1/n43"), at: GenerationId.Latest, streamId: 1);

Generation arguments (per parameter)

A diff is directional, so resolution differs by parameter — there is no single global rule:

Parameter Role Empty (0) Latest (-1)
ReadNodeAsync.at point "as of" Head Head
DiffAsync.to inclusive upper bound Head Head
DiffAsync.from inclusive lower bound 1 (earliest real generation) invalid ⇒ ArgumentException

A positive value selects that generation; a value past Head (after resolution) throws ArgumentException. The window is inclusive [from, to]: from == to yields the changes at that single generation. Past generations are reconstructed by rebuilding from genesis, so a read or diff returns the tree as it stood — never a later snapshot's state.

Lifetime and removal semantics

  • OpenStreamAsync downloads lazily as the returned stream is read (no full pre-buffering) and decrypts with the key you already hold. You must dispose the returned stream, and it is valid only while the reader and its session are alive (reading after disposal throws ObjectDisposedException).
  • Removed covers deletion, crypto-erase, and access loss: a node visible at from but purged from the to snapshot by an ACL shrink is reported Removed so the consumer can drop its local index entry. A relocation surfaces as Removed at the old path + Added at the new path (the model has no move).

Erased nodes

A node whose metadata was crypto-shredded keeps its place in the tree but has lost both its name and its content. It still appears in ReadNodeAsync and in the children of a projected subtree — hiding it would misrepresent the structure — with WorkspaceNode.IsErased == true and Name rendered as (erased). Read the flag, not the name, to detect erasure: a node genuinely named (erased) is indistinguishable otherwise.

OpenStreamAsync on an erased node throws InvalidOperationException naming the erasure. It does not return an empty stream: the content was destroyed, which is not the same statement as the file being empty.

Erasure is retroactive, which matters for point-in-time reads: a node shredded at a later generation is still projected as erased — and its stream still refused — when you read at an earlier one, where it was intact. Reading the past is not a way around a shred, because destroying the key material destroys every reading of that content.

Change-data-capture loop (shell → local index)

// Catch up from the persisted watermark to Head, one resumable pass.
await foreach (var change in reader.DiffAsync (watermark, reader.Head, scope))
{
    switch (change.Kind)
    {
        case NodeChangeKind.Added:
        case NodeChangeKind.Modified:
            // reindex when change.ContentHashes != change.PreviousContentHashes,
            // else a cheap metadata refresh; fetch bytes via OpenStreamAsync.
            break;
        case NodeChangeKind.Removed:
            // drop the local index entry.
            break;
    }
}

// Persist the next watermark = to + 1 so the inclusive window never reprocesses
// the boundary generation. The CLI `diff` cursor line carries this `next`.
watermark = reader.Head.Next;

Staying in sync (refresh, watch, continuous CDC)

IWorkspace and IBriefcaseReader open as a snapshot at the current server head. To observe changes committed by other clients/devices without re-opening, use the pull (RefreshAsync), push (WatchAsync), and the high-level fusion (WatchChangesAsync).

Continuous change stream (fire-and-forget)

WatchChangesAsync drains the catch-up from your cursor, then streams every change as generations arrive — until cancelled. At-least-once and idempotent for the shell (Added/Modified upsert, Removed drop); it persists no watermark itself.

await using var reader = await session.OpenReaderAsync (bid);

await foreach (var change in reader.WatchChangesAsync (from: cursor.Next, ct: ct))
{
    await ApplyToIndex (reader, change, ct);   // index / reindex / refresh / drop
}

Manual composition for a precise, crash-resumable cursor

RefreshAsync returns the inclusive GenerationSpan that arrived (To is inclusive, like DiffAsync), so DiffAsync (span.From, span.To) reproduces the delta exactly. Persist span.To and resume at span.To.Next.

await foreach (var update in session.WatchAsync (bid, ct))   // push notification
{
    var span = await reader.RefreshAsync (ct);               // pull to the new head
    if (span.IsEmpty) { continue; }

    await foreach (var change in reader.DiffAsync (span.From, span.To, scope, ct))
    {
        await ApplyToIndex (reader, change, ct);
    }

    Persist (span.To);                                       // crash-resumable cursor
}

A poll-only variant skips WatchAsync and calls RefreshAsync on a timer. A failed/offline refresh throws InvalidOperationException (never a silent empty span); a transient connection drop does not fault WatchAsync — the next refresh recovers any missed notification.

The span covers arrivals only: DiffAsync (span.From, span.To) reproduces the arrived delta and nothing else. A refresh may also patch generations below span.From — a retroactive ACL grant lets the refresh recover historical content you were newly entitled to. The patched range is reported through the session's warning channel; subscribe once, before the loop, to catch what arrives behind the cursor (or query session.RecentWarnings):

session.Warning += async (s, e) =>
{
    if ((e.Warning.Code == SdkWarningCode.BackfillApplied) &&
        (e.Warning.PatchedFrom.IsEmpty == false))
    {
        //  Historical content was recovered below the live cursor. Diff from
        //  the patched lower bound (the coordinate you could not guess) to
        //  head: a snapshot at exactly PatchedFrom may predate the grant that
        //  makes the recovered content decryptable.
        await foreach (var change in reader.DiffAsync (e.Warning.PatchedFrom, reader.Head, scope, ct))
        {
            await ApplyToIndex (reader, change, ct);
        }
    }
};

Optimistic-concurrency writes (lost-update protection)

UpdateDocumentAsync, RenameAsync, SetTagsAsync, and DeleteAsync are optimistically concurrent: if the targeted node changed under you (another client committed a new version, or deleted it) between your view being taken and the commit, the write throws WorkspaceStaleException instead of silently clobbering. Recover by refreshing, re-resolving the node, and retrying against the new base.

try
{
    await workspace.UpdateDocumentAsync (path, payload, ct);
}
catch (WorkspaceStaleException ex)
{
    await workspace.RefreshAsync (ct);                        // pull the concurrent version
    var fresh = workspace.Nodes.First (n => n.Path == ex.Path);
    payload = Reconcile (payload, fresh);                     // re-apply intent on the fresh base
    await workspace.UpdateDocumentAsync (path, payload, ct);  // retry; ex.ActualBase == Empty ⇒ remote delete
}

PushDocumentAsync / CreateFolderAsync are unaffected — they allocate a fresh child path, so there is no shared target to race on.

Write coordination — writability, scopes and leases

Optimistic concurrency settles races between two writes that both reach the server. A scope lease settles them earlier: an exclusive write grant over a subtree, so a second replica learns up front that the area is taken instead of losing a commit later. GetWritabilityAsync answers the "can I write here at all" question — a legacy desktop application calls it at open time to grey out editing before the first keystroke rather than failing at save.

// Is this subtree writable right now? The anchor defaults to workspace.RootPath
// (the whole briefcase).
WritabilityState state = await workspace.GetWritabilityAsync (folder);

if (state.IsWritable == false)
{
    // state.Cause tells the refusals apart: LeaseConflict (another replica holds
    // a covering lease), OfflineWithoutNomadicRight, or Maintenance (the server
    // is administratively read-only). state.BlockingLease names the holder when
    // the server confirmed one; state.Reason is human-readable.
}

// Take the scope for writing. machineName is display-only — it is what whoever
// gets blocked next sees. A read open (write: false) acquires nothing and
// always succeeds.
ScopeOpenResult open = await workspace.OpenScopeAsync (
    folder, write: true, machineName: Environment.MachineName);

if (open.Lease is { } lease)          // null on a read open or on a conflict
{
    await workspace.PushDocumentAsync (folder, document);
    await workspace.CloseScopeAsync (lease);   // releases and drops it locally
}
else
{
    // open.State carries the same reason / cause / blocking-holder triple.
}

An overload resolves the anchor from a folder name in the current view — OpenScopeAsync ("compta 2026", write: true, machineName) — so the application leases the logical set the user picked without carrying its NodePath around. It throws ArgumentException when no folder of that name exists in the view, or when more than one does.

The nomadic flag requests the offline-commit right: a root-scope write lease that lets this replica keep committing while disconnected. It is granted online and cached locally, so GetWritabilityAsync can answer from the cache once the connection is gone; without it a disconnected client is read-only (OfflineWithoutNomadicRight), and requesting any write scope while offline throws InvalidOperationException.

// Before going offline: take the nomadic right (the global offline lock).
ScopeOpenResult taken = await workspace.OpenScopeAsync (
    workspace.RootPath, write: true,
    machineName: Environment.MachineName, nomadic: true);

// … disconnected work happens here (local workspaces stay writable) …

// Back online: give the global lock back so other replicas can write again.
if (taken.Lease is { } nomadicLease)
{
    await workspace.CloseScopeAsync (nomadicLease);
}

While the nomadic lease is held, every other replica of the briefcase is blocked from writing (LeaseConflict) — release it as soon as the nomadic episode ends. The CLI equivalents are lease checkout and lease return (which pushes the pending offline work before releasing).

A lease this replica believes it holds can disappear while it is disconnected — broken by the owner or an admin, released elsewhere by the holder, or voided by an ACL revocation. Subscribe to session.LeaseLost to hear about it: it fires on reconnect reconciliation, before any save is attempted, carrying the NodeLockEntry that is gone, so the host can warn the user instead of letting the next save fail.

session.LeaseLost += (sender, args) =>
{
    // args.LostEntry.Scope is no longer ours — switch that area to read-only.
};

Importing keys from a QR code

A desktop that already holds the user's keys can show a QR pairing code (briefcases export-keys in the CLI). The mobile app scans it and turns it into UserKeys: the SDK decrypts the embedded recovery phrase with the user's password and re-derives the key set locally. The secret never travels in cleartext, and uid and password are supplied by the app — never by the QR.

// uid is the JWT `sub` from your auth flow; password is entered by the user.
var credentials = BriefcasesKeyImport.FromQrPayload (scannedText, uid, password);

var options = BriefcasesSessionOptions.FromCredentials (
    credentials,
    () => app.GetAccessTokenAsync ());

await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();

// credentials.Bid is an optional hint: open it directly when present.
IWorkspace workspace;
if (credentials.Bid is { } bid)
{
    workspace = await session.OpenWorkspaceAsync (bid);
}
else
{
    var known = await session.GetKnownBriefcaseIdsAsync ();
    workspace = await session.OpenWorkspaceAsync (
        (await session.ListWorkspacesAsync (known))[0]);
}

FromQrPayload throws a typed KeyImportException:

  • KeyImportFormatException — not a Briefcases pairing code, or malformed.
  • KeyImportIdentityMismatchException — the code was issued for another user (checked before any password work).
  • KeyImportPasswordException — wrong password, or a tampered code.

In a live camera loop, call KeyImportPayload.IsPairingPayload (text) first to skip foreign QR codes cheaply before attempting an import.

Integration seams the host app owns

  1. JWT token provider — the SDK never logs in. The app's login flow acquires the access token and hands the SDK a Func<Task<string?>>; the provider is consulted for every request (SignalR and blob HTTP), so token rotation is picked up automatically.
  2. User keys — the SDK never derives or persists key material. The app supplies the decrypted UserPublicPrivateKeys. The one exception is BriefcasesKeyImport.FromQrPayload (see above), a one-shot provisioning call that re-derives them from a scanned QR pairing code; the SDK still never persists them.
  3. Stream conventions — the SDK treats each stream as opaque bytes plus a media type; StreamId is the position in the payload. Which stream carries an image, full text, or structured JSON — and any wire format inside a stream (like the receipt JSON above) — is the app's contract, not the SDK's.

Lifecycle

BriefcasesSession is IAsyncDisposable. ConnectAsync is single-use; server-backed listing and opening (ListWorkspacesAsync, OpenWorkspaceAsync, OpenReaderAsync, WatchAsync) require a connected session, while the local trio (CreateLocalBriefcaseAsync, OpenLocalWorkspaceAsync, OpenLocalReaderAsync) never needs one. Workspaces refresh after each successful write (PushDocumentAsync, CreateFolderAsync, UpdateDocumentAsync, RenameAsync, SetTagsAsync, DeleteAsync); changes made by other clients are observed by calling RefreshAsync (or watched live — see Staying in sync above), without re-opening. After disposal, session and workspace methods throw ObjectDisposedException.

Reclaiming memory over a long session

The default in-memory store keeps every uploaded (encrypted) blob in memory for the session's lifetime. A push-only client never reads those bytes back, so for a long-lived session you can reclaim that memory deterministically: inject your own InMemoryBriefcaseStorage and call ClearBlobs (bid) after a successful push. The ledger is preserved, so the workspace stays fully usable.

var storage = new InMemoryBriefcaseStorage ();
await using var session = new BriefcasesSession (options, storage);

await session.ConnectAsync ();
var workspace = await session.OpenWorkspaceAsync (bid);

await workspace.PushDocumentAsync (workspace.RootPath, document);
storage.ClearBlobs (bid);   // the upload succeeded; drop the local blob copies
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Epsitec.Briefcases.Sdk:

Package Downloads
Epsitec.Briefcases.Identity

User identity lifecycle for Crésus Briefcases: key provisioning, at-rest key store, login, and keyset registration

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.3.3.2630 24 7/23/2026
3.3.1.2630 31 7/23/2026
3.3.0.2630 44 7/22/2026
3.2.0.2630 34 7/22/2026
3.1.4.2630 88 7/20/2026
3.1.3.2630 48 7/20/2026
3.1.2.2630 70 7/20/2026
3.1.1.2629 62 7/19/2026
3.1.0.2629 54 7/18/2026
3.0.1.2629 59 7/18/2026
2.8.1.2629 64 7/15/2026
2.8.0.2629 61 7/15/2026
2.7.3.2629 54 7/14/2026
2.7.2.2629 59 7/14/2026
2.7.1.2629 62 7/14/2026
2.7.0.2629 53 7/14/2026
2.6.2.2629 58 7/14/2026
2.6.1.2629 56 7/14/2026
2.6.0.2629 56 7/14/2026
2.5.0.2628 56 7/12/2026
Loading failed