# Technical commitments and acceptance criteria

Updated 2026-10-29. Product requirements live in [requirements](requirements.md); this page holds the technical commitments and acceptance criteria derived from them (mechanisms move to design pages and acceptance figures to the scale budget over time). The README describes current behaviour only, and the gap between what is done and what is not is in the [roadmap](roadmap.md). APIs and derived local indexes may evolve; the frozen core store format follows the compatibility commitment below.

Core wrap-up scope (2026-10-17): this round delivers the existing Core, Sync, Rust API and persistent formats, fixes real defects and completes the original full regression. Apps, platforms, new interfaces and the extreme scale targets remain separate backlog items; listing them here does not mean they are implemented or accepted.

## Phase A core freeze and first production scope

Core format version 1 is frozen by the current-writer fixture in `crates/cipherfs-sync/tests/fixtures/core-v1/`: the encrypted store, public test recovery key, trust anchor and expected plaintext are committed without the source devices' local databases. Every later version must read this fixture and subsequent production data. Existing fixtures are permanent; append new ones. The format number stays 1, changes are additive, and existing fields and core records keep their meaning. Native empty-vault recovery and independent `recover.py export-store` byte comparisons run before every commit.

The first scope is one Full device, one Partial device and one Directory or S3 store, holding 10,000–100,000 objects through the Rust API or CLI. The original system keeps copies and a fallback entry point. Raw keys contain 1–1024 bytes; objects up to 16 MiB use one unit and larger bodies automatically stream into multiple units. PATH presentation and raw objects remain separate. Authenticated HTTP object uploads use the same streaming writer. Real writes and seven consecutive days of comparison require a separate user-triggered rollout.

Production maintenance defaults disable checkpoint publication/bootstrap, GC, compaction, repacking, audit publication, multipart publication and recovery-notice management. Stores contain only `v/ s/ i/ p/ r/`; new devices replay original logs. Version-1 access roles declare ownership and read-write/read-only/admin status without enforcing Phase B refusal rules. Future refusal rules require an authenticated critical rule-version activation and cannot reinterpret version-1 history.

**Revocation invalidates only records that have not been finalized.** Phase A has no finality anchors, GC or original-log compaction. Its authority closure is recomputed at valid cutoffs, while rejected originals may remain as evidence. Phase B may add finality and maintenance while preserving core recovery. Finalized records must not roll back after late revocation; finality itself is not delivered by Phase A.

## Terms

- **Vault**: a set of files and their history sharing the same data.
- **Device**: a client that holds the vault keys and can read and write plaintext. All devices of the same user have equal rights.
- **Store**: a location that only stores and serves opaque ciphertext objects, such as S3-compatible object storage, a local directory, or a folder synchronized by iCloud/OneDrive/Dropbox. Stores hold no keys.
- **File**: an object with a stable identity; its path is an attribute. Directories are derived from path prefixes, following the S3 key model, and are not separate objects.

## Encryption

File contents, file names and sensitive metadata are encrypted before they leave the device; stores and the network see only ciphertext. Reads verify integrity; wrong passwords, tampering and corruption fail explicitly. Keys and plaintext never enter logs or stores.

## Threat model

- Stores and the network can see object sizes, counts, read/write times and the number of devices; they cannot see contents, names or directory structure.
- A store can lose data or deny service; availability comes from multiple copies, not cryptography.
- When a store replays an old state, devices do not go backwards: a device remembers the commits it has seen and only accepts progress; multiple sources are cross-checked.
- Revoking a device cannot take back content it already obtained; unfinalized records are recomputed against valid cutoffs and the authority closure, and qualified records before the cutoff remain recoverable.
- Revoking store credentials (cloud drive accounts, S3 keys) is the user's responsibility; a revoked device that still holds credentials can download ciphertext but cannot read content from the new epoch.

## Shared index

Every device keeps the complete shared index; after sync it converges, and offline it keeps the last known state. The index contains: current files, every unretired version of each file (metadata and content references), deletion state, conflict state, the information needed to decide causal order, device membership records (name, public key, role, the commit position it has synced to and when), key epochs, the vault retention policy and retirement records. The index holds only metadata and references, no bodies.

## Body sync and device retention

By default bodies sync to every device along with the index. Each device has a role:

- **Full**: keeps the bodies of all unretired versions (default).
- **Partial**: keeps only pinned directories and content it has read, fetching the rest on demand; for space-constrained devices such as phones, and also for moving rarely used files off the local disk.

When a read finds no local body, it is fetched from any device or store that holds it; small files are fetched whole, large files only the blocks the read range touches, chosen automatically. Every interface (mounts included) connects to the configured stores and peers at run time to fetch, without requiring the user to start a separate sync process. A Full device never automatically drops unretired bodies to free space.

A Partial device has a local body budget. Over budget it automatically drops bodies confirmed to exist on at least one Full device or store, preferring to keep pinned directories and recently read content; local writes are not dropped before confirmation, and bodies referenced by open handles are not dropped. Dropping affects only the local cache; it is not a synced deletion.

Commitment: **the body of every unretired version exists on every Full device that has completed sync and in every store.**

### Safe to remove

For each version you can find which devices and stores hold it, derived from the roles and synced positions in the membership records. The vault can list the versions held "only on this device"; when that list is empty the device can be removed safely. A Full device that is still catching up is treated as Partial until it has caught up.

### First sync

A newly joined Full device first obtains the index, then the bodies, newest versions first; transfers can be interrupted and resumed.

## Stores

A vault can configure zero or more stores, which carry the index and ciphertext bodies. A store only needs to: write immutable objects, read by name (range reads preferred), list by prefix, and delete. It does not need locks, atomic overwrite, conditional updates or immediate visibility; objects are content-addressed and append-only, each device publishes its own commit head, and other devices merge along the causal chain. Incomplete or not-yet-visible objects count as "not arrived", not as corrupt.

Devices of the same vault can use P2P connections and stores at the same time; when two devices are not online together, they exchange data through a store.

Stores connect in two ways: **folder** — reads and writes only a local directory that the iCloud/OneDrive/Dropbox client moves around; no authorization, but only usable on computers with that client installed; **API** — direct HTTP access with credentials, usable from phones, desktop apps and servers. Implementation order: S3-compatible and local directories (including cloud-drive sync folders); then WebDAV (covering self-hosted and several cloud drives); OneDrive/Dropbox/Google Drive private APIs only when the mobile clients are built.

Requirements for all stores:

- Remote objects are packed to reduce the object count; remote packs target a smaller size than local ones (on the order of 4–8 MiB), so that both whole-pack downloads and range reads pay off.
- Object names include the length or hash; an inconsistent read counts as not arrived and is retried, not reported as corrupt.
- After retirement takes effect, store objects are reclaimed after a further short delay to cope with eventual consistency; content addressing means a mistaken deletion only needs a re-upload.
- Fetch by block when range reads are available; otherwise fetch whole objects.
- **Credentials are issued per device, and writer devices have no delete right by default**: each device holds its own store credentials with write, read and list rights only; deletion belongs only to the role that runs GC or is granted explicitly by the user. Where supported, enable bucket object lock or version retention with a retention period no shorter than the GC delay, so a compromised device or ransomware cannot delete ciphertext. Revoking a device prompts rotation of that device's credentials. Credentials come from pluggable sources (environment variables, files, systemd credentials, the system keychain, a remote key service) and are never written to the vault or logs.
- Terms: buckets, directories and cloud drives are **stores** and see only ciphertext; the side of the daemon that speaks S3 to applications is the **front door** and sees plaintext. The two are never mixed up.

### Multi-volume stores

A Directory store can consist of several volumes and still appears to the vault, to other devices and to `recover.py` as one StoreId. Each volume is an independently mounted directory holding the same `cipherfs-store.json`, the `s/` marker and a volume descriptor; the object layout on a volume is exactly that of a single-volume Directory store, the core format does not change, and core readers ignore the volume descriptor.

- **Placement is per file.** Each FileId is assigned a fixed set of `copies` volumes (default 2, from 1 up to the number of volumes); the body packs of every version of that file are written only to those volumes, and each of them holds the complete body. A file's body is never split across volumes. A body pack only merges units of files that share the same volume set.
- Log segments `i/`, the marker `s/`, recovery objects `r/` and their multipart carriers are written to every volume. Handing any single volume to the reference exporter yields files that are either complete or absent, never partial; missing files are listed by name.
- The "stored in storage" wait level holds only once all `copies` volumes of that file have persisted it; an object present on only some of its volumes counts as not arrived and is completed by the next sync round.
- A new file chooses volumes by free space; a single file cannot exceed the free space of any candidate volume, and a write that cannot be placed fails explicitly rather than splitting the file or lowering the copy count.
- The file-to-volume map is derived state, rebuilt by listing the volumes; the device only caches it, and losing the cache never affects correctness.
- Maintenance commands only copy and verify and never delete objects from the logical store: **adding a volume** lets new files flow to it with no rebalancing; **draining a volume** brings every file on it up to `copies` on the other volumes, verifies each object by name and then marks the volume detached, after which a person removes the disk; **repair** restores `copies` for every file from other volumes or from devices that hold the body. The zero-DELETE production profile is unchanged.
- With a volume absent the store degrades: reads use another volume holding the file, writes continue while at least `copies` volumes are online, and otherwise fail explicitly.
- Stage B compaction and repacking merge packs only within one volume set.
- Acceptance: with a real six-volume directory fixture at `copies = 2`, every file is complete on exactly two volumes and `i/ s/ r/` are identical on all six; after a drain and a repair, both devices' `fsck` and the independent export match byte for byte; a single-volume independent export contains only complete files and lists the missing ones; a write larger than any volume's free space fails explicitly; client RSS stays within 512 MiB throughout, the store contains only the five object classes with zero DELETE calls, and the permanent fixtures are byte-identical.

## File metadata

Modification time is metadata that is committed to sync. Other attributes such as the executable bit are kept on a best-effort basis: the index always records them and the API can always read them, but a mounted filesystem may not present them. Permissions, owners, xattrs, ACLs and hard links are not promised.

**Content type and attributes.** Every file version carries a required content type (lowercase `type/subtype`), and every file version and explicit directory entry carries an open list of key-value attributes. A writer derives the type from the bytes on each content write, never from the file name, and records the image size and, for a JPEG, the embedded EXIF thumbnail as a range (`cipherfs.preview`); the writer ends a content unit at the end of that preview so a Partial device can read it alone. The `cipherfs` namespace is reserved for such derived values; every other namespace is free, and its values are opaque bytes that every device preserves. Setting an attribute publishes a new version with the same content and path, like a move, and concurrent edits are conflict heads. Mapping attributes to filesystem xattrs is not part of this.

## Versions, history, trash and conflicts

Each file's versions form a causal chain. Deletion is a tombstone version; moves and renames are versions that change the path attribute and produce no history or trash entry. Concurrent modifications follow the file identity and do not resurrect at the old path because the other side moved the file. Directories exist through their files; deleting a directory deletes every file under it. `mkdir` publishes an explicit directory entry, so empty directories sync and merge when two devices create the same path; `rmdir` tombstones every entry at the path.

**Local-only entries.** Junk such as `.DS_Store`, `._*`, `Thumbs.db` and `desktop.ini`, or a build directory such as `target/`, cannot be refused (builds must run) and cannot be hard-coded, so it is a rule in data: the shared vault policy carries gitignore-syntax `local_only_patterns` (`init` writes the defaults) and `local_only_rule_files` (default `.cipherfs-local`), each device may add its own patterns (never synchronized), and rule files in the synchronized tree apply per directory, nearest first, with `!` negation. Priority from lowest: policy, device, rule files. A new file or directory that matches, or that is created below a local-only directory, is local-only: it lives only in a local table with a single version, its body in the local content store, listed with `local_only: true` everywhere, never part of the signed log, snapshots, synchronization, GC, retention, history, trash or conflict views. The decision is made once, at creation; renaming keeps it; a published entry that arrives at the same path replaces the local-only file and directories merge. A `publish: force` parameter on the create and write APIs publishes a new matching entry anyway. Export leaves local-only entries out unless asked.

History, trash and conflicts are three views of the same version chain; all are in the shared index and visible to every device:

- **History**: earlier versions of a file.
- **Trash**: files whose current version is a tombstone, shown folded by prefix; a whole prefix can be restored.
- **Conflict**: a file with several heads that have no causal order.

Every version records its source device and time.

### Current version

When there is a causal order, the later version wins. For true concurrency: a content version beats a deletion; otherwise the later publish time wins, then the change identifier breaks ties. The rule only decides which version the directory shows; all heads are kept.

Different files competing for the same path concurrently (concurrent creation of the same name, a move onto an existing path) are handled as a conflict on that path: the path shows one version, the rest are conflict heads of that path, and resolution keeps one or renames. The key model allows `a` and `a/b` to coexist; when mounted the directory wins, and the file `a` is visible through the conflict view.

### Presenting and resolving conflicts

Directories and mounts show only the current version and never create conflict copy files. `list`/`head`, the CLI and desktop App show the number of conflicts and each head's source device and time; mounts offer a read-only virtual directory to inspect each head.

A conflict ends when some change has all heads as parents. An explicit resolution picks one head without changing content; when a device that has seen all heads writes to the file, the new change automatically has all heads as parents and the conflict is resolved. The losing heads become history versions.

### Restore

Restoring a history version or taking a file back from the trash publishes a new version whose content reference points at the old body, without re-uploading the body; the result takes part in sync.

## Retirement and cleanup

A version leaves the shared history in only two ways, both synced operations:

- **Explicit purge**: emptying the trash, purging the history of given versions or files.
- **Vault retention policy**: synced with the index, such as retention days for versions and trash and a minimum number of versions kept per file. The default retention is 365 days. Any online device evaluates it and publishes retirement records; other devices accept them rather than computing their own.

Retirement first enters a **pending retirement** state that any device can cancel during the grace period (default 30 days); only after it expires do all devices and stores drop the bodies and reclaim objects. Retirement records published by a revoked device after its revocation are always rejected.

Never retired automatically: current versions, unsynced local edits, and conflict heads. An explicit purge cannot delete the current version. A version referenced by a local open handle keeps its body locally until the handle closes. Device-level settings only decide whether a body is kept locally; they cannot make a version disappear from the shared history.

## Keys, devices and revocation

- The vault has a randomly generated master key; content keys are derived per **key epoch** and **subtree key**, and each version records its epoch. Subtree keys are reserved for a future read-only share of a directory; all files currently use the root subtree.
- Every device has a long-term device key pair and a human-readable name. Joining the vault wraps the current epoch key to that device's public key and writes it into the membership record; device-to-device authentication follows the membership records.
- **Pairing**: the new device shows a short code or QR code, and confirming it on any joined device admits it; when no joined device is present, it joins with the initialization information and the recovery key.
- **Revocation**: opens a new epoch whose key is wrapped only to the remaining devices; the revoked device cannot read content written afterwards and other devices no longer accept its connections. Existing content is not re-encrypted. Any enrolled device can revoke; concurrent revocations take effect in causal order, first one wins. In vaults with stores, revoking a device prompts the user to rotate that device's store credentials.
- **Periodic epoch rotation**: the vault policy can set a rotation period (off by default, 90 days suggested); when due, any online device opens a new epoch and wraps it to every unrevoked device. A leaked epoch key only decrypts content up to that epoch. Disclosed content cannot be taken back. The rotation period and the notice period below live in one shared vault policy record; any device with management rights can change it, and the causally latest change wins.
- **Management rights of a recovery device (notice period)**: a device that joined with the recovery key can read and write immediately, but revocation, approving pairings, rotating the recovery key and ratifying other recovery joins need either ratification by an independent existing member or the notice period: the device publicly publishes a management claim that takes effect after the notice period set by the vault policy (default 7 days, minimum 1 day). If any existing member (joined before the claim, not revoked) objects during that time, the claim is void, and the objector may then revoke the device and rotate the recovery key. An existing member may object at any time before it accepts the "claim activated" record; when a program meets an activation record on which the local device has not yet decided, it must ask the user first. Unattended services by default accept automatically only after a full notice period has passed since they first saw the claim. Objection is always an explicit action. The notice period protects control: a stolen recovery key means the content is already readable, and the notice period does not change that.
  The notice period is implemented in Core, background sync, the CLI, HTTP and the Rust/C/Python/Go bindings. `recovery claim|claims|accept|object|activate` provide the notice and explicit decisions; `recovery auto-accept false` makes unattended mode warn only, and `policy set recovery-notice-days N` changes the duration of later notices. The duration is fixed at the claim's causal position; later policy changes do not shorten a notice in progress. `/health` shows the number of notices awaiting a local decision; the Dioxus desktop App displays notice banners with explicit acceptance/objection. Desktop notifications remain part of the future native shell.
- The device list shows name, role, last sync time and synced position, and is the entry point for revocation and "safe to remove".

## Local unlock, sealing and recovery

- The local storage key and device seed are wrapped separately by a set of **unlock sources**; wrappings can coexist and be added or removed individually, changing only local state and never shared state: password; recovery key; service key (file, environment variable, systemd credential); remote key service (KMS, Secrets Manager, self-hosted HTTPS, with cloud platform identity or a revocable token); system keychain and secure hardware (Secure Enclave, TPM, Android Keystore, for biometric unlock). Passwords do not sync; changing a password only updates the local wrapping, does not re-encrypt files and does not affect other devices.
- **Sealed** is a first-class vault state: the process is running but the keys are not in memory. Seal on start, seal after an idle timeout, seal when a phone goes to the background; operators or users unseal through one interface; servers configured with a remote or service key source unseal automatically after restart.
- The device private key can optionally be bound to secure hardware so that signatures never leave the hardware and a copied disk image cannot impersonate the device; a recovery join then creates a new device identity instead of keeping the old one, and the documentation explains this trade-off.
- In-process key material is zeroized, memory is locked against swapping, core dumps are disabled, and keys never enter logs; tests or reviews cover these key requirements.
- A table states what each unlock source protects against and what it does not: disk image or backup leaks (password, remote unsealing and hardware binding protect; a service key file does not); root access or memory reads while running (none protect); the store provider (all protect); a virtual machine provider reading memory (none protect).
- The recovery key is generated once when the vault is created and kept by the user; it unlocks any device's local wrapping and can join a new device with the initialization information. It can be rotated: each device re-wraps when it syncs the rotation record, and the old recovery key stops working with the old epoch.
- A forgotten local password is reset with the recovery key; the device identity stays the same.

## Format and algorithm upgrades

Field and algorithm evolution of shared objects never rewrites the original bytes of existing authenticated records. Shared wire format 1 is frozen now that the recovery notice records have landed; the local databases use metadata schema 1 and CAS schema 1 and do not read earlier development formats.

- **Encoding**: index segments, snapshots, upload manifests, pairing messages, local database values and vault configuration use protobuf. Fields are only added, never removed or redefined; retired field numbers are reserved and never reused. Ordinary unknown fields may be skipped in projections, but unknown, missing or mixed record types reject the whole segment; filtering them and advancing the frontier is not allowed. Keeping unknown authenticated body bytes does not mean unknown semantics can be applied.
- **Identity is bytes**: hashed or signed records carry the original bytes the writer encoded once; readers authenticate first and then decode, and never re-encode to compute identities. Reopening, snapshots and forwarding preserve them verbatim. Versions, retirements, member enrollment and authority proofs follow this rule; derived views of member names, roles and capabilities do not modify the enrollment preimage. Pairing remains a keyed BLAKE3 MAC/SAS over the original transcript, with no separate signature.
- **Algorithm identifiers**: hashes, signatures, public keys, KDFs, AEADs, chunking and object-name encodings use separate registry numbers. Public digest/public key/signature text is `algorithm-<base32>` in lower-case Crockford base32, such as `b3-`, `ed25519-`, `x25519-`; 16-byte FileId, SubtreeId, StoreId and VaultId use unprefixed base32 (FileId is key-derived; the others are random) and do not pose as hashes. Every ID and object name is case-insensitive-safe; each store declares its object-name encoding in `cipherfs-store.json`. Public JSON identities use the canonical strings and reject legacy hex, wrong categories, whitespace and invalid encodings. The registry records active/deprecated status and cutoff times; unknown, unimplemented-for-the-purpose or expired algorithms must be rejected, with no downgrade.
- **Vault-pinned algorithms**: the Genesis record pins the vault's algorithm set; non-conforming records are rejected. Switching algorithms while running needs a new signed policy record; the mechanism is defined in an ADR and not implemented yet.
- **Format numbers and capability announcements**: the `CFSSEG02` header carries the format and algorithm numbers, authenticated by the signature/AAD; the currently supported maximum is 1. Enrollment binds `max_format >= 1`, and a device may update its own current capability; writes use the minimum of the local actual maximum and the announcements of all unrevoked members. After full outer signature verification, a higher format fails explicitly and stops before candidate key processing and application; ordinary imports from an already authorized author persist a block at that position and on later segments, while other independent devices continue. Bootstrap cannot skip the block, nor can a block be established from an unauthenticated header.
- **Local database**: the protobuf settings of metadata and CAS store the schema number; only an explicitly new file initializes the schema (metadata 1, CAS 1). A missing, corrupt or zero schema in an existing database is reported as corruption and any other schema number as unsupported; nothing is rebuilt silently and nothing is migrated. The existing encrypted and unencrypted boundaries stay, and the configuration labels its KDF explicitly.
- **Object names**: follow the [current architecture](architecture.md#persistence-and-public-identities), using canonical algorithm text, hashes and lengths; recovery object names bind the epoch, complete wire hash and length. Legacy index snapshots are removed. Objects with unknown algorithms are logged as diagnostics and ignored; they cannot authorize anything or justify GC deletion.

## Scale targets

The target is 1,000,000 current files, 1 TB of bodies across all unretired versions, 10 devices and a 100 GB single file. GB/TB are decimal: 1 GB = 1,000,000,000 bytes, 1 TB = 1,000,000,000,000 bytes; KiB/MiB/GiB are binary, 1 GiB = 1,073,741,824 bytes. The following are acceptance budgets that must be measured, **not executed results**; the million-file and 100 GB long-running acceptance runs are still in the backlog.

| Reference configuration | Resources and storage |
| --- | --- |
| Partial mobile-class device | arm64, 4 CPU cores, 4 GiB RAM, 256 GB storage; keeps the complete index; during acceptance pinned-directory bodies are at most 8 GiB and the read cache budget is 8 GiB; storing 1 TB of bodies is not required. Protected unconfirmed writes and open handles still follow the body retention rules. |
| Full lab device | 8 CPU cores, 16 GiB RAM, SSD; each device's capacity must cover 1 TB of unretired bodies, the full local index and staging overhead; stores, other Full devices, Partial caches and optional export copies on the same disk count separately, with 20% headroom reserved. The three copies of the standalone 100 GB scenario need at least 300 GB; in practice at least 360 GB must be free and satisfy the ciphertext/index/staging capacity formula. |

The reference link for timing is a shared 1 Gbit/s (1,000,000,000 bit/s) total bandwidth between source and target, round-trip time at most 10 ms, no injected loss and no server-side throttling; storage sustains at least 150 MB/s sequential read/write (MB is decimal). The exact CPU, OS, disk, limits and measured link must be recorded; a high-end host or a VM that is only resource-limited is not execution evidence for phone hardware.

| Item | Acceptance ceiling and boundary |
| --- | --- |
| Device memory | During cold start, index sync, page queries and body sync/verification, each device's process-tree peak RSS and measured cross-thread live heap each stay within 512 MiB (536,870,912 bytes); the sum of process-tree RSS for 10 devices running at once stays within 5 GiB, with store services counted and reported separately. Index, runtime, password unsealing and body selection metadata may not be subtracted. |
| Cold start | With the complete million-file index already local, the network off, and process and OS page cache cold: from process start to unlock completed and the first limit=256 directory page returned, at most 30 s. |
| Page queries | On the static scale fixture, each limit=256 page call for directories, object key prefixes and history takes at most 5 s; scan CPU time is recorded separately. Bounded results do not mean O(limit) scan cost. |
| First index sync | With the complete fixture already published to the store and the target holding no index/body cache: from starting a configured new device to the complete index and exact frontier converging, at most 1,800 s; Partial and Full are accepted separately, and bodies may not be downloaded wholesale in advance. With 10 devices online at once (1 published source and 9 targets joining concurrently) sharing the link, all indexes converge within 3,600 s. |
| Full first body sync | From first index convergence until all 1 TB of unretired bodies are available, streaming digest verification passes and missing_units=0: at most 21,600 s; both phases of a single target's first sync together at most 23,400 s, newest versions first, resuming after interruption. |
| 100 GB sync through a store | Exactly 100,000,000,000 bytes generated as a real stream, transferred through an independent source vault, a store and an empty Full target vault: source body import at most 1,800 s, publish at most 3,600 s, target index at most 300 s, target bodies at most 3,600 s, streaming read-back verification at most 1,800 s, the whole run at most 14,400 s. Directory Range and the S3 whole-pack fallback when Range is ignored are accepted separately. |

Fixtures, timing start and end points, cold/warm caches, process/heap measurement, capacity calculation, resumption and failure conditions are in the [scale budget acceptance procedure](scale-budget.md). Streaming body windows, single-object limits and page result limits cannot replace the whole-process budgets of this section; current limits such as the 32 MiB authority proofs/snapshots may make scale acceptance fail, and these targets cannot be claimed as met before the long-running acceptance has run.

### Object-scale target (not implemented or accepted)

An additional object profile targets 39,000,000 write-once objects, about 1.8 TB of bodies, 13,500 writes per day and 16 random reads per second, with one Partial device, one Full device and one S3 store. Keys are arbitrary byte strings of 1–1024 bytes, isolated from file paths. A stable vault-wide secret derives object identities; concurrent writes to the same key retain conflict heads. Objects whose actual length is up to 16 MiB use a single unit without type sniffing or preview splitting, including unknown-length inputs; larger bodies automatically stream into multiple units. This profile does not replace the general-file, long-history, 100 GB single-file or mobile-device targets above.

| Item | Required measurements and evidence |
| --- | --- |
| Incremental cost | Ordinary point reads/writes, idle sync rounds and one incremental maintenance pass perform no unconditional whole-vault scan. Compare scanned/decrypted rows, transactions and LIST/GET/PUT counts at 10^4, 10^5 and 10^6 objects; these counts must not grow with the total object count. Initial indexing, complete traversal, checkpoint construction, policy recomputation and fsck may grow with relevant data, but must stream, bound memory and resume. |
| Million-object baseline | An idle round takes less than 100 ms, vault open less than 1 s, local indexing no more than 900 B per object including actual redb allocation, auxiliary tables and free pages; a single-object write uses one pack fsync and one redb commit. State cold/warm conditions, audit mode, database cache, all persistence costs and measurement boundaries separately. |
| Two-device full-key acceptance | Keep every real key and length; use synthetic bodies of the real lengths with independent Partial and Full vaults and a Directory store. Bodies may be truncated to the host's actual disk capacity; key count may not be truncated. Record Full indexing time, empty Partial checkpoint bootstrap time, idle counts, local bytes per object and hit/miss P99. If real keys cannot be obtained, use 10^7 synthetic keys and explicitly report that real-key acceptance was not executed. Directory results do not count as live S3 network acceptance. |
| Memory | Client process-tree RSS and live heap each remain within 512 MiB. A server profile separately states `database_cache_bytes`, other settings and its own RSS/heap ceilings; server results cannot replace client acceptance. |
| Queries and settings | Objects and paths use separate namespaces; hash-ordered pages return opaque cursors. Each S3 front-door bucket fixes `list_order=hash|lexical`; lexical directories above the configured ceiling fail rather than silently changing order. Thresholds carry units, documentation and combined validation. Adaptive controls have deterministic rules, min/max bounds, change events and pinning; they never adjust shared safety policy. |

**The finality target narrows revocation: revocation invalidates only records that have not been finalized.** A finality anchor makes the covered versions, admissions, retirements, closed slots and GC authorizations immune to late revocation, withdrawn revocation and permission cascades. There is no promise to erase compromised-device records after joint finalization. The unfinalized suffix is still recomputed using valid cutoffs and the permission closure. Anchors bind their predecessor; eligible signers are all unrevoked, non-dormant members at that predecessor. A candidate cannot reduce its own signer set, and each device durably locks one candidate per predecessor before signing. Missing required signatures stall irreversible reclamation; local clocks and offline ages cannot bypass them.

Physical deletion executes only exact anchored plans; grace time is an additional lower bound. A doomed PackId cannot become a durable dependency again, and moving a designated survivor requires an anchored handoff first. Shared anchors determine compaction and late-branch rebasing, irrespective of whether a device still holds old stubs. These finality and object capabilities are not implemented yet; budgets, protocol review and short regressions do not establish scale acceptance.

## Server scenario

An application uses the vault as its storage layer: the local copy is only a hot cache, the full data lives in stores and on other devices, and missing bodies are fetched on demand. It shares the same core, the same owner-process model and the same sync with the personal multi-device line; only the shell differs.

- **Daemon** `cipherfs serve`: a separate process holds the vault and serves it over a Unix socket or TCP; a configuration file declares the listen addresses, each vault's path and unlock sources, the stores and their credential sources; it provides readiness and health endpoints and can serve several vaults. Application processes never touch keys; multi-process applications share one daemon. Embedding in-process is only for desktop apps and single-process tools.
- **S3 front door**: the daemon speaks S3 to applications, which use existing SDKs and migrate by changing the endpoint. Buckets map to vaults or prefixes, keys to paths, Range GET to range reads, multipart uploads to streaming writes; the versioning API exposes history and concurrent heads, the ETag policy is configurable, and presigned URLs are supported. It listens on loopback only by default; listening elsewhere must be enabled explicitly and requires TLS; every request is SigV4-signed, the Host header is checked, and Basic authentication is not supported; front-door keys are issued per application, rotatable, and unrelated to store credentials. The front door sees plaintext; the machine running the daemon is the trust boundary. Vault-specific capabilities (write durability acknowledgement, pinning, device and store management) are offered through a JSON API.
- **Write durability acknowledgement**: a write can specify a wait level — locally persisted, acknowledged by at least one store, acknowledged by at least N Full devices — and returns only once it is met.
- **Application-grade retention policy**: very short retirement and grace times are allowed, so deleted attachments can be reclaimed quickly.
- **Pinning and prefetch**: pin individual files and prefetch by recent access heat to reduce cold-read latency.
- **Direct read/write by FileId**, so applications need not maintain a path mapping.
- A server is a device with its own identity and the same choice of roles as other devices; a daemon without a configured role defaults to Partial with a budget. A compromised server is handled as a device revocation.

## Application access

Applications access the vault by paths and bytes. Rust `SyncedVault`'s `put_from_reader`, `get_to_writer` and `get_range_to_writer` bound the body transfer buffers; `put` borrows bytes the caller already has, and `get`/`get_range` return a Vec whose memory is O(object or requested range size). Core's counterparts are `write_from_reader`, `read_to_writer`, `read_range_to_writer` and `write_file`/`read_file`. Bounded body buffers are not the same as a fixed whole-process memory footprint.

Lists and history provide bounded result pages: Core `list_page` lists a directory's direct children (directories before files of the same name), `list_file_entries_page` and `SyncedVault::list_page` list files by literal object key prefix; Core `file_history_page` and `SyncedVault::history_page` list history in descending time and VersionId order. Each page holds at most 1024 items and limit must be in 1..=1024; the continuation value is bound to the vault and directory/prefix, and for history also to the path and FileId; it holds no database, file handle or server-side cursor and can be serialized and used across close and reopen. Each call reads the current index; consecutive pages do not promise a cross-page snapshot; concurrent inserts, deletions or retirements change later results, and a history continuation fails explicitly when the path is reused by another FileId. On a static vault the concatenated pages equal the order and results of the corresponding convenience call.

The Vec convenience calls stay; the Vec results of `list`, history, trash and conflicts use O(number of returned items) memory. Page calls only bound the result items kept (during a scan at most limit+1 items plus one record being decrypted); encrypted index ordering may require rescanning for each page. The full membership/index state, visited IDs and pending parent versions of history traversal, file heads/path contenders and the body unit references of a single version still have costs that grow with the data. C/Go provide streaming callbacks, Python provides reader/writer, and HTTP downloads can stream; their bytes/Blob and list convenience calls still allocate the full result, and they currently lack the Rust list/history page calls above.

With sync enabled, background tasks sync, reconnect and retry automatically. A successful write means local persistence is complete, with remote replication following; cancelling a reader upload before EOF publishes no partial object, while a commit accepted after EOF may still complete even if the waiter cancels. Buffered reads that fail return Err and never return a partial object as success; writer/HTTP streams may output an authenticated prefix before failing, and the caller must discard the output or abort publishing the target.

## Access audit

Design finalized 2026-10-18; implemented with current-entry functional acceptance on 2026-10-24 (see [Access audit](access-audit.md)); the current signed index, version history, runtime logs and HTTP tracing do not constitute an access audit. The audit is a **file access log**: which device (and on a server, which application) did which operation on which VersionId of which FileId, when, with what result and how many bytes. It is a server-line capability; the personal multi-device line shares the same capability but has it off by default. The full design (fields, transaction boundaries, container, acceptance) is in a design document in the development repository and is summarized in [ADR 006](adr/006-access-audit.md).

- The audit is a unified Core capability reused by every interface; it covers access through Core, the Rust API, the CLI, bindings, FUSE, the desktop App, HTTP API and S3 front door, including cache hits, history/conflict version reads, failed attempts and refusals in the sealed state; background sync, prefetch, retirement and GC are recorded separately under a system identity.
- **Stored like bodies**: each device writes its own stream, with a key derived from the vault epoch key and the device ID, signed by the device private key and chained, sealed into immutable objects and published to a separate prefix in the configured stores. It does not enter the shared index and does not affect FileId, VersionId, member rights, file retention or the frontier; index sync and GC neither reference nor delete it.
- **Every unrevoked device in the vault can read it**, by file (who touched this file) or by device (what this device did); revoked devices cannot read logs from the new epoch; the recovery key can read the whole history. There is no separate audit recipient or collection server.
- Only IDs are recorded: the target is the FileId and VersionId; directory operations record the directory's FileId; a lookup of a non-existent name records only the parent directory and one miss. Events contain only typed allow-listed fields and never record passwords, keys, credentials, tokens, request bodies, file names, paths or free-form error text.
- Three modes, configured per entry point: `off` is explicitly disabled; `best_effort` never samples and may degrade but must show loss counts and gaps; `required` refuses new business access when the audit intent or commit fact cannot be persisted, and never degrades silently. The daemon's HTTP/JSON and S3 front door default to `required`, mounts provided by the daemon default to `best_effort`, and the embedded library and CLI default to `off`. A file change and its commit record are written in the same local transaction.
- Log retention is independent of file history retention; cleanup is done only by devices holding credentials with delete rights, and writer devices cannot delete published logs.
- External status must state the mode, the covered entry points, unfinished operations, backlog, loss count and the last persisted/published sequence numbers. Signatures and the chain let any member verify that a device's stream has not been rewritten or had segments removed; an unpublished local tail is not promised to be tamper-proof, and an attacker controlling the running process can forge later records; the design claims nothing stronger.
- Acceptance follows a checkable entry-point coverage table: partial entry points, `best_effort`, or tests through ordinary logs alone do not count as a complete audit delivery.

## Interfaces

The core contains encryption, the file model, storage transactions, version and sync rules; interfaces only adapt: they do not copy the database, do not define their own rules, and do not report unimplemented operations as successful.

Main interfaces: Rust API, CLI, FUSE mount, desktop App, HTTP API and the daemon S3 front door. C/Python/Go bindings follow the Rust API and add no features of their own. The earlier WebDAV/S3 gateway and TUI prototypes are outside the acceptance scope; the daemon front door follows the server requirements above.

On Partial devices, mounts keep system indexers, previews and backup tools from scanning everything and pulling every body back to the local device; files not held locally are listed as usual and fetched only when read.

## No lock-in

A Full device that has completed body sync relative to its locally accepted index frontier, with a complete local cache, can export selected current files and optional unretired history/conflict bodies offline without any other device or store online; this does not mean it has seen new changes not yet synced to it. A Full device still catching up and a Partial device can only export the bodies they hold locally offline; `export_plaintext` explicitly lists the missing target paths and cannot promise a complete export; retired bodies are not guaranteed to be recoverable.

The export target must be a new directory that does not exist, its parent path must exist and contain no symbolic links, and the selected keys and archive paths must be representable losslessly by the target's native filesystem; `a` alongside `a/b`, case collisions or native name/path collisions fail explicitly, with no automatic renaming. `ExportOptions` selects history and conflicts independently; older non-head versions are archived under their original paths, and extra heads under the current export path of their FileId; once selection completes, the versions and bodies are kept until the export ends, and concurrent changes or retirements do not change that selection. Bodies are streamed out, and selection metadata grows with the export size; corruption, authentication and I/O errors abort; the missing report only counts unavailable bodies, and every successful file has complete content.

### Self-describing vault

Data must be recoverable without this software. The vault carries everything needed to read it, on by default and optionally off when the vault is created; turning it off only affects whether the description files are written, not the format.

- **Specification**: the vault root holds a plaintext `FORMAT.md`, exact to the byte: directory layout and object naming, the ID text alphabet and store descriptor; the byte layout of container headers such as `CFSSEG02` and which bytes enter the signature and which enter the AEAD AAD; every step of the key chain (password → Argon2id parameters → local wrapping → master key → epoch key → subtree key → content key) and the literal text of every BLAKE3 domain label; the public standard reference for each ID in the algorithm registry; the independently distributed `cipherfs.proto` source; and the step-by-step procedure to recover the current file list and bodies holding only the password or the recovery key. The document is written from the reader's point of view, not reverse-engineered from the implementation.
- **Test vectors**: the vault root holds public golden vectors and their expected outputs, covering every step of the specification, for implementers to check step by step.
- **Reference decryptor**: a single-file script in a general-purpose language other than Rust, depending only on implementations of public standard algorithms, limited to listing files and exporting plaintext with the password or recovery key, with no sync or writes. It ships with the vault and with release packages and is the executable form of the specification.
- **Consistency enforced by tests**: the `FORMAT.md` in the vault is byte-identical to the specification file in the code repository; magic numbers, HKDF labels and algorithm parameters in the specification are compared one by one with code constants, never copied by hand; full verification must use the reference decryptor, without Rust code, to decrypt the vector vault and match the plaintext.
- Stores can optionally keep the same specification and decryptor (on by default, optionally off when adding a store). This plaintext only describes the format and contains no keys or user data; object names already reveal that a store belongs to this software, so it adds no leak.

## Explicitly not promised

- Under true concurrency the default current version depends on device clocks; it only affects presentation and loses no data.
- In a P2P-only vault without stores, two devices that are not online at the same time cannot exchange data.
- No multi-user sharing or permissions; every device in a vault has the same rights.
- Versions retired after the grace period ends are no longer guaranteed recoverable from any device.
- When a Partial device reads a file it does not hold and no device or store holding the body is reachable, the read fails explicitly and does not hang. Buffered calls return Err and never return partial content as success; writer/HTTP streams may already have output an authenticated prefix before a late missing-body or other error, and after failure the caller must discard the output or abort publishing the target.
