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
<PackageReference Include="Epsitec.Briefcases.Sdk" Version="3.3.3.2630" />
<PackageVersion Include="Epsitec.Briefcases.Sdk" Version="3.3.3.2630" />
<PackageReference Include="Epsitec.Briefcases.Sdk" />
paket add Epsitec.Briefcases.Sdk --version 3.3.3.2630
#r "nuget: Epsitec.Briefcases.Sdk, 3.3.3.2630"
#:package Epsitec.Briefcases.Sdk@3.3.3.2630
#addin nuget:?package=Epsitec.Briefcases.Sdk&version=3.3.3.2630
#tool nuget:?package=Epsitec.Briefcases.Sdk&version=3.3.3.2630
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
UserPublicKeysit is handed. Binding those keys to the right person is the discovery step's job (Identity.GetPublicKeysAsyncvalidates theml-kem-768profile 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 freshreplica.json, hence a fresh identity) is read-only — a write throwsOfflineWriteNotAuthorizedException, in agreement with whatGetWritabilityAsyncreports (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
OpenStreamAsyncdownloads 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 throwsObjectDisposedException).- Removed covers deletion, crypto-erase, and access loss: a node visible
at
frombut purged from thetosnapshot by an ACL shrink is reportedRemovedso the consumer can drop its local index entry. A relocation surfaces asRemovedat the old path +Addedat 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
- 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. - User keys — the SDK never derives or persists key material. The app
supplies the decrypted
UserPublicPrivateKeys. The one exception isBriefcasesKeyImport.FromQrPayload(see above), a one-shot provisioning call that re-derives them from a scanned QR pairing code; the SDK still never persists them. - Stream conventions — the SDK treats each stream as opaque bytes plus a
media type;
StreamIdis 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 | Versions 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. |
-
net10.0
- BouncyCastle.Cryptography (>= 2.7.0-beta.98)
- Microsoft.AspNetCore.Http.Connections.Client (>= 10.0.10)
- Microsoft.AspNetCore.Http.Connections.Common (>= 10.0.10)
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.10)
- Microsoft.AspNetCore.SignalR.Protocols.Json (>= 10.0.10)
- Microsoft.Extensions.Configuration (>= 10.0.10)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Http (>= 10.0.10)
- Microsoft.Extensions.Http.Resilience (>= 10.8.0)
- Microsoft.Extensions.Logging (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Logging.Configuration (>= 10.0.10)
- Microsoft.Extensions.ObjectPool (>= 10.0.10)
- Microsoft.Extensions.Options (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
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 |