This document describes the durability guarantees, their storage assumptions, and how interrupted operations recover. For operator commands, see backup and restore.
Guarantees
- An acknowledged submit is durably committed (redb
Durability::Immediateplus flushed objects and an independently durable metadata archive) under the storage assumptions below. - 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.
- A submit is entirely accepted or not accepted: one redb transaction.
- History is immutable; deleting a current file appends a delete record.
- 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. - Detected corruption is reported (
doctor,verify), never rewritten. - An interrupted
migrateis 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. - Lost responses cannot duplicate operations: the client persists an OperationId before transmission; the server records every outcome under it.
Publication sequence
| step | action | fault point |
|---|---|---|
| 1 | receive into quarantine (objects/staging, same filesystem); hash while receiving | |
| 2 | publish chunk batch: rename loose files, flush each touched shard directory and the loose directory once; append pack and flush it; locator commit | before/after-object-publish |
| 3 | publish manifests (same mechanism; chunk presence validated first) | |
| 4 | publish change set (manifest presence validated first) | |
| 5 | prepare 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 | |
| 6 | publish 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 roots | before/after-commit-envelope |
| 7 | enter 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) | |
| 8 | one 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 sequence | before/after-metadata-commit |
| 9 | flush the committed outbox into the independent archive, publish durable HEAD, then acknowledge | archive-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
| platform | file data | directory entry | note |
|---|---|---|---|
| Linux | fsync(2) via File::sync_all | open directory, fsync | required for rename durability on ext4/xfs |
| macOS | fcntl(F_FULLFSYNC) (std's sync_all) | same on the directory | asks the drive to flush its cache; latency depends on the storage device |
| Windows | FlushFileBuffers | handle opened with FILE_FLAG_BACKUP_SEMANTICS, FlushFileBuffers; only ERROR_INVALID_FUNCTION (1) and ERROR_ACCESS_DENIED (5) mean "not flushed"; all other errors propagate | Both 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
| failure | guarantee | mechanism |
|---|---|---|
| process crash at any point | acknowledged submits persist; unacknowledged submits are absent or complete; retry resolves | fault-point kill tests; operation records; pack tail truncation to committed length |
| OS crash / power loss | same, assuming the storage honours flushes | flush ordering above; redb two-phase commit; requires separate VM or hardware interruption testing |
| partial write | detected: envelope length checks, digest verification on read, pack tail truncation | verify, read_object |
| disk full / I/O error | the operation fails before acknowledgement; nothing partial is accepted | every write, flush, rename, and commit result is checked |
| corruption of stored bytes | detected on read and by doctor (or verify --full); reported per object; never repaired silently | digests |
| total storage loss | recovery to the last backup; changes after it are lost | backup/restore; incarnation change forces clients to reconcile |
| external workspace writers | fingerprint 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/stagingshare 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 byserveand by every offline maintenance command. A second process fails immediately.checkpointandintegrity-statustake 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 bothopenand 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.redbbefore the submit request is sent. On an unknown outcome the nextscribe submitasksoperation_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,submitdurably archives the old candidate in.scribe/_state/recovery/submit-<operation>.pendingbefore 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 deleterecords 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, andrevertrestores 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 --continuerefuses andmove --abortretires 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 --continueresumes application;merge --abortrestores 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 --offlinedurably records local intent and aneeds_reconcilemarker, 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 --updateleaves 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.