Durability & recovery
Documentation/Run your server
Durability & recovery18 min read

Durability, publication, and recovery

On this page

This document describes the durability guarantees, their storage assumptions, and how interrupted operations recover. For operator commands, see backup and restore.

Guarantees

  1. An acknowledged submit is durably committed (redb Durability::Immediate plus flushed objects and an independently durable metadata archive) under the storage assumptions below.
  2. Accepted revisions never reference unpublished objects: manifests are accepted only when their chunks are present, change sets only when their manifests are present. Offline object collection retains every accepted historical object graph and every active upload intent.
  3. A submit is entirely accepted or not accepted: one redb transaction.
  4. History is immutable; deleting a current file appends a delete record.
  5. Interrupted commands cannot silently discard local work: modified or unexpected files are moved to .scribe/_state/recovery, never overwritten. Even displaced files that equal their recorded base are retained, so concurrent editor saves remain reachable. Recovery cleanup is explicit.
  6. Detected corruption is reported (doctor, verify), never rewritten.
  7. An interrupted migrate is restartable: each metadata upgrade commits its converted records together with a durable cursor, schema 3 is recorded in the repository database before the descriptors are rewritten, and a rerun completes whichever step or marker is missing. Repeating a completed upgrade is a no-op, and startup refuses a root with pending upgrades instead of migrating it.
  8. Lost responses cannot duplicate operations: the client persists an OperationId before transmission; the server records every outcome under it.

Publication sequence

stepactionfault point
1receive into quarantine (objects/staging, same filesystem); hash while receiving
2publish chunk batch: rename loose files, flush each touched shard directory and the loose directory once; append pack and flush it; locator commitbefore/after-object-publish
3publish manifests (same mechanism; chunk presence validated first)
4publish change set (manifest presence validated first)
5prepare the candidate outside the gate: read repository head and branch head, match the lock policy, validate repository config, stage immutable snapshot pages/history in bounded transactions with undo inventory; candidate = head + 1
6publish the candidate's commit envelope (durable), still outside the gate; it links the identities accepted at the branch head and, for a merge, at the merged head, plus the prepared content and revision rootsbefore/after-commit-envelope
7enter per-repository gate; confirm repository head, branch head, and policy revision are unchanged, otherwise release the gate and return to step 5 with a fresh candidate (bounded: the eighth attempt reserves the submit turn before preparation, while keeping preparation outside the publication gate)
8one short transaction: recheck operation, heads, lock epoch and chain links; publish prepared roots, commit ledger, operation outcome, rename-lock visibility and upload cleanup marker; advance branch head and repository sequencebefore/after-metadata-commit
9flush the committed outbox into the independent archive, publish durable HEAD, then acknowledgearchive-frame-/archive-cursor-

Steps 2 to 4 are normally one put_objects batch for small submits (2 file flushes and 1 to 2 directory flushes server-side); large submits use as many batches as the 1024-object / 64 MiB batch limits require. No network and no asset hashing happens inside the gate or the transaction. Lock acquire and release are separate short transactions on the same database and therefore serialize with acceptance; the perf report records their latency.

Every submit holds the repository submit-turn mutex from final head confirmation through acceptance. The eighth candidate takes this mutex before preparation, preventing another submit from superseding it while leaving the publication gate free. Snapshot traversal, allocation, encoding, and envelope I/O stay outside the publication gate on every attempt. Future pages/history are staged in bounded transactions; only the final metadata transaction makes them accepted/reachable. Rejected or superseded preparation is removed using its durable undo inventory.

The gate covers step 7’s confirmation, step 8’s transaction, and its archive acknowledgement barrier. Archive I/O runs after releasing the redb writer. The envelope flush in step 6 goes through the object store's single pack writer, so it may queue behind an unrelated bulk upload; that wait does not extend the gate hold. A candidate whose captured heads move before acquiring the submit turn is discarded before writing its envelope. Lock/policy races after envelope publication can still leave an unreferenced envelope. Object existence is not acceptance: only the metadata transaction accepts history, doctor walks accepted commits 1..=head and verifies every stored object by digest without treating an unreferenced one as a problem, and verify reports per object. The candidates_superseded counter includes discarded drafts, and the attempts field of submit.committed records preparation retries. Branch creation validates its fork point the same way: the first-parent walk of the source branch runs before the gate against one metadata snapshot, and the gate rechecks only the source's activity and head bounds.

crates/scribe-server-cli/tests/kill_publication.rs aborts the server process at each named publication fault point during a submit, restarts it, resolves the unknown outcome from the client, and checks an independent oracle: exactly one accepted change, every acknowledged change readable, no orphan in history. A crash after the envelope is durable but before the gate leaves exactly the unreferenced envelope described above; the replayed submit publishes a fresh envelope and accepts once. crates/scribe-server/tests/publication.rs drives concurrent submitters and head churn between candidate capture and submit-turn acquisition through the in-process adapter.

Flush sequence per platform

platformfile datadirectory entrynote
Linuxfsync(2) via File::sync_allopen directory, fsyncrequired for rename durability on ext4/xfs
macOSfcntl(F_FULLFSYNC) (std's sync_all)same on the directoryasks the drive to flush its cache; latency depends on the storage device
WindowsFlushFileBuffershandle opened with FILE_FLAG_BACKUP_SEMANTICS, FlushFileBuffers; only ERROR_INVALID_FUNCTION (1) and ERROR_ACCESS_DENIED (5) mean "not flushed"; all other errors propagateBoth path-based and rooted directory flushes use this classification. The unsupported case relies on NTFS journaling; Windows storage guarantees still require Windows qualification.

Order per batch: staged file data flushed at stage time -> loose renames -> shard directory flushes (once each) and the loose directory flush -> pack appends -> pack file flush (once per pack) -> locator commit (redb flushes its own file) -> metadata commit -> archive data/HEAD publication. The loose flushes precede the pack writer lock, so a batch with no packed object never takes it; every byte an entry points at is durable before the single locator transaction either way.

Failure model

failureguaranteemechanism
process crash at any pointacknowledged submits persist; unacknowledged submits are absent or complete; retry resolvesfault-point kill tests; operation records; pack tail truncation to committed length
OS crash / power losssame, assuming the storage honours flushesflush ordering above; redb two-phase commit; requires separate VM or hardware interruption testing
partial writedetected: envelope length checks, digest verification on read, pack tail truncationverify, read_object
disk full / I/O errorthe operation fails before acknowledgement; nothing partial is acceptedevery write, flush, rename, and commit result is checked
corruption of stored bytesdetected on read and by doctor (or verify --full); reported per object; never repaired silentlydigests
total storage lossrecovery to the last backup; changes after it are lostbackup/restore; incarnation change forces clients to reconcile
external workspace writersfingerprint mismatch detected; files changed during read/upload are refused; races with uncooperative writers remain (documented)analyze re-stat, re-hash on upload

Assumptions

  • Storage honours flush requests (no write cache lying about completion).
  • The data root and its objects/staging share one filesystem.
  • Only one process owns a data root at a time: enforced by an exclusive OS file lock on <data>/LOCK (flock/LockFileEx), taken by serve and by every offline maintenance command. A second process fails immediately. checkpoint and integrity-status take no data-root lock: the first asks the running server to do the work, and the second reads saved reports from the runtime directory.
  • Diagnostic logs are never used for recovery; recovery reads repository state.

Client-side recovery

  • Workspace creation: .scribe/_state/ is populated first and its descriptor published last, as a flushed temporary renamed into place with the directory flushed. The descriptor is the commit point, so creation either completes or leaves a .scribe/_state/ without one. That state is reported as an interrupted creation by both open and a repeated creation, naming the directory: move it aside, or delete it when it holds no local work, then create the workspace again. It is never completed in place, because its database may hold baselines and opened work belonging to an earlier identity.
  • Submit: the candidate (change set payload, fingerprints, OperationId) is in .scribe/_state/state.redb before the submit request is sent. On an unknown outcome the next scribe submit asks operation_outcome: accepted -> finalize; rejected -> report; no recorded outcome -> replay the exact same operation ID and intent, including merge ancestry and its repository incarnation. Absence in the same incarnation is not proof that a submit cannot still be accepted. If restore has changed the incarnation and no outcome is recorded, submit durably archives the old candidate in .scribe/_state/recovery/submit-<operation>.pending before clearing the pending row. It retains local opened files and tells the user to sync and resolve or revert old bases. Archive publication is exclusive; retries verify existing bytes and never overwrite another archive. Before immutable object uploads, the client persists a separate upload intent ID and sends it with pins. Uploads cannot accept history. They finish before the complete replayable submit candidate is recorded. Interrupted uploads can be re-analyzed and repeated using content-based presence checks.
  • Sync: the journal is written before any file changes; each batch marks its entries done after their files are in place; the watermark advances only when every entry is done. A rerun resumes. Files that could not be replaced (Windows sharing violations) are reported and retried on the next run. With repository mounts, the parent watermark covers the parent tree alone: it publishes once every parent entry is applied, while an unavailable or unfinished child stays reported and is retried by the next sync.
  • Delete: scribe delete records the opened deletion before removing the file (to .scribe/_state/recovery, including files that equal their base). An interruption therefore leaves an opened deletion whose file is still present; repeating the delete completes the removal, and revert restores the file from the recorded base. A batch answers per path: a refused or failed path is reported as skipped and the remaining paths are still processed.
  • Moves: each pending move has a durable ID, journal, and dependencies. Multiple completed local moves can await one submit; an interrupted move must be resumed or aborted before another filesystem move starts. Abort dependent moves first, retaining the latest local bytes. Work in transit remains under .scribe/_state/staging/move-<operation>, outside both the temporary area (cleared on open) and the recovery area (pruned by the user). If that entry is gone as well as the source and the destination, move --continue refuses and move --abort retires the intent, saying that nothing was restored: the bytes are already lost, and refusing would only block the workspace. Do not revert individual move endpoints. See move recovery.
  • Merge resolution: a choice for a rename/collision group is saved atomically before its files are changed. merge --continue resumes application; merge --abort restores the pre-merge target layout and retains displaced files, including edits made after a recorded choice. Unrelated target changes preserve the plan; a grouped merge whose path dependencies changed must be aborted and replanned.
  • Offline work: edit --offline durably records local intent and a needs_reconcile marker, without claiming an exclusive lock. Reconciliation checks revisions and acquires required locks but does not rewrite files or baselines. After interruption, retry it; already acquired locks remain held. Submit still performs authoritative validation. See offline work.
  • Restore: a restored server has a new incarnation. The server refuses new submits carrying an old incarnation, preventing reused change numbers from passing stale base checks. The next sync re-registers the workspace (including authenticated ownership lost with the backup) and performs a full reconcile. Opened files remain deferred, and their baselines retain the old incarnation until explicitly resolved or reverted; advancing the workspace incarnation does not make those old bases eligible for submit. resolve --update leaves these paths for an explicit choice without fetching potentially lost base objects.

status, scan, and history queries inspect without repairing work. A partial status report retains local findings and marks unavailable server information explicitly; it is not proof that a workspace can submit.

Historical get reconstructs and verifies one file in a unique sibling staging directory, flushes the verified file, then publishes exclusively to the requested destination and flushes the directories through the platform contract. Existing destinations are never overwritten. If the final directory flush fails, the command reports that the verified file has already been published; inspect it before retrying with a new destination. Workspace state and cache are unchanged.

Backups

scribe-server backup copies descriptors, catalog, auth-state.redb when present, per-repository metadata databases, and object stores (locator, loose, packs) with a checksum manifest verified after the copy, while holding the ownership lock (no writer exists). A metadata export alone is not a backup. Place backups on independent storage; the recovery window is the backup interval. restore verifies every file against the manifest, assigns new incarnations, and runs structural verification before reporting success.

Physical backup and restore flush copied files and their directory entries before reporting success. Backup manifests and rewritten restore descriptors/markers use atomic file publication with a directory flush. Metadata import propagates a failed directory flush instead of reporting a successful activation. Their crash tests cover maintenance-copy-file-durable, maintenance-copy-directory-durable, and the shared checkpoint-file-durable, checkpoint-renamed, and checkpoint-directory-durable publication windows.

Auth service data

The auth data directory is backed up separately, as a copy of the whole directory taken while scribe-auth serve is stopped. The password pepper and the revision witness live outside it and outside its backups. Every applied administrative change and every logout advances the auth revision in the same Immediate transaction and then rewrites the witness atomically, so a copy put back by hand is refused at startup (behind its witness) and by every storage server (behind the revision it recorded in auth-state.redb); it would otherwise revive credentials revoked since the backup. The one way back is scribe-auth restore: it copies the backup into a new, empty directory, starts a new incarnation, moves the revision past both the backup and the witness, disables every earlier credential (keeping server credentials only with --keep-server-credentials), writes a recovery administrator credential file before committing, then rewrites the witness and writes the marker last. If it stops before writing the marker, remove the new directory and the recovery credential file and run it again. See rollback protection.

scribe-auth migrate (schema 3 to 4) writes the new administrator credential file before converting records and replaces the marker last; its crash tests cover auth-migrate-bundle-durable, auth-migrate-records-durable, and auth-migrate-marker-staged. Offline administrator recovery (scribe-auth admin-credential issue) flushes the credential file before its record commits and is covered at auth-admin-credential-secret-durable. Neither command adopts an existing credential file. If migrate stops after writing the file but before converting records, or offline issuance stops before its commit, remove the file and run the command again; once migrate has committed the records, a rerun only writes the witness and the marker.

Working-copy publication and interrupted planning

Sync rebuilds the plan when it finds an unfinished journal, using bounded changed-path pages in either direction. This includes journals whose planning stopped before the first page or between pages: their target alone is not evidence of a complete plan. Before replacing the journal, it persists the minimum and maximum revisions that may have been partially applied. Repeated interruptions and target changes therefore cannot forget files from an earlier partial update. Initial sync, view changes, and restored incarnations still use paged full reconciliation. The watermark advances only after the rebuilt plan has been applied (opened paths remain recorded as deferred). A repository incarnation change discards the old target and triggers reconciliation against the restored repository.

Planning persists the target with its first nonempty page, before applying any file. A failure before that page leaves the previous journal and watermark intact. Completed materialization batches are checkpointed before the next batch; the last batch's completion and watermark advance share one Immediate transaction. This keeps partial progress recoverable without advancing past unapplied files.

Deferred updates remain recorded until their files are successfully applied. Sync and revert clear the deferred target in the same transaction as the baseline update; a failed or interrupted revert retains its retry target. An accepted submit clears deferred targets it supersedes; recovery of an older acknowledgement preserves any newer sync target. Every sync derives deferred targets from the requested snapshot in bounded batches rather than replaying a target from a newer revision. Full reconciliation flushes deletion runs page by page; shrinking a large tree never buffers the remaining baselines in a single journal append.

Before sync or revert replaces a local entry, or a delete removes it, the entry is moved to a unique name under .scribe/_state/recovery, with an index entry beside it (<name>.origin: the original path, the reason, the entry's operation id and the time) so every retained file can be mapped back. The index is written before the rename, so a crash between the two leaves an index without content, which scribe recovery list names and prune clears, never content nobody can attribute. No file copy is needed, but recovery consumes disk space until entries are removed, and Scribe never prunes the area on its own: scribe recovery list shows what is there and scribe recovery prune --older-than <duration> or --all removes retained entries; intent archives (submit-<operation>.pending and workflow archives) and entries with an unreadable index are always kept and reported. Reports associate each displaced path with its recovery location, summarised to a count when more than ten files were displaced at once.

Displaced files are retained even when their fingerprint and content hash match the recorded base. A hash check cannot authorize a later deletion: an editor can save between the check and the rename, or keep writing through an open handle after the rename. Moving every displaced entry into indexed recovery keeps those writes reachable. No working-copy entry is moved into temporary storage for automatic deletion. Updates and view narrowing therefore retain the old selected files until explicit pruning; budget space for that growth. Close editors using retained files and review the entries before pruning.

File size and modification time remain status optimizations; neither grants permission to discard local content. Non-forced explicit delete verifies the content id before accepting the deletion. Replacement files are published with an exclusive rename, so a concurrent creation at the destination causes a reported failure, not an overwrite. Original contents stay in recovery if publication fails, and retrying sync rebuilds the journal plan.

Publication traverses directory handles without following symlinks. Linux and macOS use relative openat and exclusive rename operations; Windows opens directories without delete sharing and rejects reparse points. Existing file/directory transitions apply deletions before additions, preserve unexpected local contents, and defer updates that overlap opened paths. Platform-specific recovery tests must run on the corresponding operating system.

Sync and whole-tree export neither promise nor attempt power-loss durability for materialized files. MaterializeOptions::flush_filesystem is off by default, so a batch does not flush the whole filesystem on its way out. Nothing downstream trusts materialized bytes to be durable: the server is authoritative, the journal and its watermark live in the workspace database and commit durably on their own, an interrupted sync replays every entry not yet marked done, and file size and modification time are status optimizations that never grant permission to discard local content. On macOS and Windows the platform reports whole-filesystem flushing unsupported and the call does nothing, so enabling the option narrows the window only on Linux. There it runs syncfs, which writes back every dirty page of the volume, so each batch stalls behind unrelated writers; macOS deliberately avoids sync(2) for the same reason. File reconstruction and recovery renames remain ordered by the sync journal. After an OS crash, scribe scan --verify detects missing or changed working-copy bytes; restoration is an explicit operation, and existing local contents are retained in recovery. A process interruption still resumes through the sync journal. This policy does not change the mandatory file, directory, and database flushes for server submits or the durable local state.

Publication error recovery and repository creation

A failed pack append invalidates its writer. Before another batch can publish, the store discards uncommitted buffers and reads the durable locator checkpoint. It truncates the checkpoint pack's uncommitted tail and removes newer packs belonging only to the failed batch. Opening a store applies the same rule, so a process killed between a pack rollover and its locator commit leaves no orphan pack behind: the checkpoint is authoritative, and every pack with a higher id is removed before the checkpoint pack is reopened. Accepted objects remain readable, including when an earlier metadata commit had an uncertain return status. Normal appends retain buffered writes and one pack flush per batch.

The locator itself is an index. scribe-server rebuild-locator recreates it offline under the data-root lock: every pack entry and loose file is re-hashed and only bytes matching their claimed identity are indexed, so a torn or unflushed body can never be mistaken for the object a later upload would otherwise be deduplicated against. Unverifiable entries are reported, not indexed, and the new database replaces the old one only after it is durable.

Concurrent loose-object publishers use distinct, exclusively created staging files even for identical content. Each publication flushes both the shard and its parent; it never relies on another publisher to make a new shard durable. Raw and compressed uploads allocate a fresh random name for each staging file, so concurrent uploads cannot claim the same staging file. Cancelling an upload removes only its own staged files.

Repository creation flushes every newly created directory link, the descriptor, the populated repository directory, and its parent before marking the catalog row ready. Required flush failures propagate. Windows retains the platform's explicit directory-journaling contract.

Structured uploads validate file-state size/content against manifests, each chunk length, and complete file content identity before publication, outside the gate and metadata transaction. Validation memory is bounded by chunk size.

Submit rejects linked ancestors during analysis and upload. Permission updates must target the verified local file even if its pathname changes concurrently.

Inspection opens catalog, repository metadata, and locator databases using redb's native read-only mode. A database needing recovery is reported instead of repaired. Normal startup may recover database state; doctor and verify never do. They report staging leftovers, pack tails, and uncommitted packs without cleaning, truncating, or removing them, and leave repositories outside a selection untouched.

Metadata database loss and retention

Writable metadata reads and successful mutation/retry results pass the archive barrier. A publication failure fences that database handle until restart can archive its surviving committed outbox. It does not turn accepted metadata into a definite rejection. If the database is destroyed before archival completion, that unacknowledged interval may be lost; an independent surviving archive protects acknowledged metadata. Asset-volume recovery still needs verified object backups. The default adjacent archive is not a separate failure domain.

Offline consolidation verifies a full logical base before advancing retention floor and pruning covered journal rows/segments. Page reclamation retains every historical root. Stream sequences are persisted independently and never reused. Process-kill tests cover append and rollover, cursor publication, base publication, pruning, relocation, both replay codecs’ batch/final commits, and page reclamation. These process tests do not replace power-loss testing on supported storage. See operator recovery.

Source docs/durability-and-recovery.mdSnapshot 2b8dcccb