Alpha software, not for production data. Bugs can cause data loss, formats may change without migration, and the cryptography has not been independently audited. Keep your own backups and check that you can restore from them.
Varsto

Alpha-0 storage format and sync model

From docs/spec/alpha-0-format.md in the repository, rendered 2026-10-12.

Format version 0, alpha. Implemented in crates/varsto-core. It may change without a migration path; every released alpha keeps a reader (see format-versions.md). Nothing here has been reviewed or audited. Post-quantum hybrid signatures and key exchange exist since 0.0.1-alpha.3 (section 11); algorithm identifiers in every object allow later changes.

1. What alpha-0 does

  • Content-defined chunking of files (Gear rolling hash, FastCDC-style normalisation; block sizes by file size: below 32 MiB 64 KiB to 1 MiB, average 256 KiB; below 1 GiB 256 KiB to 4 MiB, average 1 MiB; larger files 1 to 16 MiB, average 4 MiB; the size alone decides, so the same file is cut the same way everywhere; section 29).
  • Client-side authenticated encryption of every chunk, manifest, ledger batch and record before it reaches a storage. Storage sees opaque objects and their sizes only.
  • One storage backend: a local directory (covers local disks, removable disks and network mounts). S3 and rclone remotes follow.
  • A per-device, append-only, signed ledger replicated through every storage. Devices that are never online at the same time converge through the storage ("mailbox").
  • Two-way folder sync between devices with version vectors; concurrent edits produce a deterministic winner and a conflict copy; deletions go to a local trash.
  • fsck: compares the ledger with what the storages actually hold and can verify every object by hash.
  • Cold storages are written but never read (requirement F-043 default).
  • A local desktop interface (varsto desktop): a web page served from the binary on 127.0.0.1 with a per-session token and a Host check, the first shape of the local control API (plan 6.8, 6.32).

Not in alpha-0 (see section 10 and 11 for what later alphas added): sharing with other users, peer-to-peer transfer, hybrid post-quantum signatures and key agreement, FIDO2 security keys, Strongroom, placeholders, policies and alerts, packs, mobile, MCP, daemon. Keys are shared between devices out of band (the "vault key").

2. Identifiers

Identifier Construction Visible to storage
vault id 16 random bytes yes (vault/meta.json)
device id first 16 bytes of BLAKE3(device public key) yes (object names)
folder id 16 random bytes yes (object names)
chunk id keyed BLAKE3 of the chunk plaintext, key = folder hash key no (inside encrypted manifests and ledger)
object name BLAKE3 of the chunk ciphertext yes (object names)
content hash keyed BLAKE3 of the whole file, folder hash key no

Object names are plain hashes of ciphertext, so a peer or a scrub can verify an object without any key. Chunk ids are keyed, so the storage cannot test whether a known file is present (plan 6.11).

3. Keys

Subset of key-hierarchy.md. All keys are 256 bits. Derivation uses BLAKE3 derive_key with the context e2ee-sync-format/0/<purpose> and length-prefixed scope identifiers (vault, folder, device, chunk) in the key material.

Key Purpose Derived or wrapped
master key (K3) root of the vault random; shown once as the hex "vault key" for joining other devices; wrapped locally under the passphrase key
passphrase key (K4) wrap the master key and device signing key on one device Argon2id (fixed minimums: 64 MiB, 3 passes, 1 lane; readers refuse weaker parameters)
device signing key (K6/K7) sign this device's ledger batches alpha.3: Ed25519 + ML-DSA-65 hybrid (alpha-0: Ed25519 only), random per device, wrapped with K4
ledger key encrypt ledger batch bodies derive(K3, ledger)
device-registry key encrypt device records derive(K3, device-registry)
folder-record key encrypt folder records derive(K3, folder-record)
local-keyring key encrypt the local copy of folder keys derive(K3, local-keyring)
folder key (K9) per folder random; published in the creating device's folder record
folder hash key (K11) chunk ids and content hashes derive(K9, dedup-hash, folder)
folder metadata key (K10) manifests derive(K9, folder-metadata, folder)
chunk key (K14) one chunk derive(K9, chunk-key, folder, chunk id)
chunk nonce one chunk derive(K9, chunk-nonce, folder, chunk id), first 24 bytes

Alpha-0 decision (convergent chunk keys). The chunk key and nonce are derived from the chunk's keyed hash, so the same content encrypts to the same object on every device of the vault. Two devices that ingest the same file offline therefore produce one object, deduplication works without coordination, and a future peer-to-peer swarm can advertise object names. The cost: rotating a folder key re-encrypts its data, and anyone holding the folder key can test for known content inside that folder. Shared folders keep convergent keys inside a key epoch and get a new random folder key when a member is removed (section 24, which explains why random per-chunk keys were not chosen). Rationale and alternatives: plan section 8, "Avainnettu hash, lohkot ja padding".

4. Encryption and associated data

AEAD is XChaCha20-Poly1305. Every object is nonce (24 bytes) || ciphertext || tag. The associated data is a length-prefixed list that always starts with the format version and the AEAD identifier, then the object type and its scope:

Object Associated data fields
chunk vault id, folder id, chunk id, plaintext length
manifest vault id, folder id, device id, manifest sequence
ledger batch body device id, batch sequence
device record vault id, device id
folder record vault id, creating device id, folder id
key file vault id, device id
local keyring vault id, device id

A storage cannot move, rename or replay an object into another place without the AEAD check failing.

5. Storage layout

vault/meta.json                          format version, vault id (plaintext)
vault/devices/<device>.enc               device record: name, public key (Ed25519 || ML-DSA-65; 32 bytes = legacy Ed25519 only)
vault/folders/<device>/<folder>.enc      folder record: name, folder key
vault/storage-id.json                    random identity of this storage (plaintext, first writer wins)
vault/storage-names/<device>/<utc>.enc   a device's storage names and their identities (section 20)
ledger/<device>/<seq 16 digits>.json     signed batch envelope (body encrypted)
ledger/<device>/checkpoint-<seq 16 digits>.json  signed checkpoint of that device's batches up to seq (section 22)
vault/share-*/<folder>/...               share epochs, keys, grants, member records and tokens of shared folders (section 24)
vault/access/<folder>/<device>/<utc>.enc a device's last-accessed times for a folder (section 13)
org/manifest/<seq>-<attempt>.json        organization: administrators, signed by the root key (section 27)
org/roster/<seq>-<attempt>.json          organization: people, devices, policy, signed by an administrator device (section 27)
org/log/<device>/<seq>-<attempt>.json    organization: one administrator device's chained log (section 27)
manifests/<folder>/<device>/<seq>.enc    full folder view of one device
chunks/<first two hex>/<object name>     encrypted chunk

Every path has one writer: a device writes only under its own device id, and chunk objects are content-addressed and written with put-if-absent. No conditional writes, locks or leases are needed on the storage. Objects are never overwritten.

6. Ledger

Model B of ledger-signing-notes.md: events are grouped into batches; each batch carries the device id, a sequence number, the hash of the previous batch of the same device, a Lamport clock and its events; the body is encrypted, and the signature covers device || seq || BLAKE3(encrypted body). Event types:

Event Meaning
device_enrolled this device joined (name)
folder_added this device created a folder
chunk_stored this device wrote an object to a storage (a claim)
chunk_verified this device fetched the object from a storage and the hash matched
chunk_on_device this device holds the plaintext as part of a file
manifest_published this device published manifest seq of a folder
storage_retired the storage was removed from the vault: claims on it made before this event no longer count (section 20)
chunk_dropped this device deliberately removed the object from a storage: claims on that storage made before this event no longer count, later ones count again (section 23)

A batch may hold event types a reader does not know (written by a newer version); since 0.0.1-alpha.9 such events are read as unknown and ignored, and the rest of the batch counts. Earlier versions reject the whole batch (section 23, Compatibility).

Replaying every batch yields the location view: for each (folder, chunk) the object name, size, which storages claim it (by whom) and which devices verified it there. Applying a batch is order-independent: sets are unions, times are maxima, and where events disagree (the object name and size of a chunk, a device's name) the event with the largest (Lamport time, device, sequence, position) wins. A device therefore keeps the view in ledger/view.enc (zstd-compressed JSON, XChaCha20-Poly1305 under a key derived from the device's root key, associated data vault and device) with the last sequence number applied per device, applies only new batches, and rebuilds it from the batches when the keys it can open or the revocation cut-offs change. Retirements, replica claims and forks are applied on top of the cached view at every read, exactly as after a full replay. A storage copy counts as verified only when a device other than the writer has verified it. varsto status reports chunks without any storage copy and chunks verified elsewhere.

Mailbox rules:

  • on every pull a device lists, on every hot storage and for every device whose key it knows, only the batches after the newest one it holds of that device (list_after: S3 start-after, a directory listing elsewhere), and ingests them after checking the signature against the device registry and the hash chain; a batch under a key the device does not hold (a shared folder it is not a member of, a key epoch it has not adopted yet) is kept unread and read once the key arrives, instead of being downloaded again on every pull;
  • on every push it uploads its own batches that a storage lacks, listing from the newest batch it last confirmed there (ledger/pushed.json); when that batch is gone the storage is filled again from the first batch;
  • two different batches with the same device and sequence number are a fork: the device is marked forked in the local heads (so are a checkpoint that contradicts a batch and two different checkpoints at the same sequence number, section 22);
  • if the mailbox holds batches of this device's own identity that it never wrote (a copy restored from an old backup, or a clone), the device fences itself: it stops signing and push fails until it is re-enrolled as a new device.

Batching: one user action seals at most one batch. A sync seals one batch for the pull and push of every folder together (and pulls the ledger once); applying a placement suggestion and acting on a selection of files in the interface seal one batch each; a verification run seals one. Fetching a single file does not seal a batch: its chunk_on_device and chunk_verified events wait in ledger/pending.json for the next batch (usually the next sync) and count in the device's own view meanwhile. Enrolment, joining a shared folder, folder creation, storage removal and Strongroom changes are sealed and pushed at once, because other devices act on them.

Lamport clocks: each device keeps one logical clock, incremented on every local change and raised to any larger value seen in batches or manifests.

7. Manifests and merging

Each device publishes its full view of a folder as a manifest (seq increases per device). A file state has: path, version vector (device -> clock of that device's last change), deleted flag, size, mtime (change detection only), content hash, chunk list (chunk id, object name, size), and the device and clock of the last change.

Merge rules for the same path, local versus remote:

  • remote dominated by local: keep local; local dominated by remote: take remote;
  • concurrent with identical content: keep, version = element-wise maximum;
  • concurrent, one side deleted: the modification wins (a deletion never destroys a concurrent edit);
  • concurrent, different content: the state with the larger (clock, device id) wins; the loser is kept as name.conflict-<device>-<clock>.ext, a new file with its own version; the merged version vector is the element-wise maximum so both devices converge without a further round.

Deleted files are moved to <home>/trash/<folder>/<path>.<time> on the device, never removed outright (F-025).

8. Local device directory

vault.json      vault id, device id, device name, format version
keys.enc        master key and signing key, wrapped under the passphrase
keyring.enc     folder records known to this device (encrypted)
config.json     storages and folder mounts (no secrets)
devices.json    device registry cache (public keys)
org-state.json  the organization as last verified: manifests, roster, log (section 27; absent without one)
org-request.json  a device waiting for an organization's approval: its request keys (removed on join)
clock.json      Lamport clock
ledger/         local copy of every device's batches, the heads file, pushed.json (own batches confirmed per storage), pending.json (events waiting for the next batch) and view.enc (the cached location view, encrypted)
state/<folder>.json  merged file states, local index, last seen manifests
trash/          deleted files

9. Known limitations

These were the limits of alpha-0; the sections below record what later versions changed.

  • Pairing was "copy the vault key". Since alpha.7 a nine-digit code pairs devices over the LAN (SPAKE2); since alpha.10 an organization approves a device through a token sealed to its hybrid KEM key (section 27). There is still no QR code and no second factor. The vault key is the master key in hex; losing it and every device means losing the data (no escrow).
  • alpha-0 signed with Ed25519 only; since alpha.3 every batch carries sig_alg = "ed25519+ml-dsa-65" (section 11). The sig_alg field made the switch possible without rewriting stored data.
  • Whole files are chunked in a stream, but a changed file is re-read twice during a push (once to hash, once to upload) when a chunk is new; packs for small files do not exist yet.
  • Manifests are full views; very large folders will need incremental manifests (listing and pruning exist, section 25).
  • Verification of cold copies (section 20), policies (section 14), placeholders (section 10), sharing (sections 10 and 24) and P2P (section 15) exist since the versions those sections name.
  • Logging and the redaction rules of docs/architecture/logging.md are not implemented; the CLI prints only aggregate counts and identifiers.
  • The passphrase is NFKC-normalised before stretching since alpha.10; key files written earlier by a passphrase that normalisation changes are opened with the raw string as a fallback.

10. Additions in 0.0.1-alpha.2

Background service, tray and menu-bar apps

varsto service run watches attached folders (inotify, kqueue, FSEvents or ReadDirectoryChangesW through the notify crate), syncs within seconds of a change and on a timer, serves the local interface and writes service.json (pid, port, token, mode 0600) so that the tray app, the CLI and the mobile shells can find it. On Linux and macOS the service runs as a systemd user unit or a launch agent shared by the app and the command line, set up by any command once a vault exists (until varsto uninstall); varsto tray (Linux: StatusNotifier over D-Bus; Windows: system tray, which starts and supervises the service as a child process) and the macOS app only start a service when none answers. A supervisor restarts the service after a self-update (exit code 75). varsto service install registers a login item (LaunchAgent, autostart entry, Startup folder).

Self-update

varsto update, and the automatic check in the page, the tray and the menu bar (which offer an update only when a newer version exists), fetch manifest.json, SHA256SUMS and SHA256SUMS.sig from the download page with the system curl (base URL https://varsto.net/downloads/, one constant in update.rs, overridable with VARSTO_UPDATE_URL; the old address varsto.soderlund.in keeps serving /downloads/ for older clients). The update is refused unless SHA256SUMS.sig verifies against the release public key built into the binary (release-key.pub; minisign format: Ed25519 over the BLAKE2b-512 hash of the file, plus a signed trusted comment that must name the version being installed), the archive named in the manifest is listed in the signed file, and the downloaded archive has that SHA-256. Second channel: the SHA256SUMS attached to the GitHub release v<version> is fetched too; if it is reachable it must list the archive with the same checksum, otherwise the update is refused; if GitHub cannot be reached (or the release has no checksum list), the signature alone suffices. Then the running binary is replaced (rename-over on Unix, rename-aside on Windows). varsto verify-release <file> [--github] runs the same checks on a file downloaded by hand. The private key is kept offline by the maintainer; see RELEASING.md.

File-manager integration. varsto paths fetch|free <paths> downloads or frees files given by their paths on disk, through the running service (POST /api/paths) or, without one, on the vault directly; varsto open-placeholder <path> downloads a placeholder and opens the file with the desktop's default application. On Linux, varsto install adds a MIME type application/x-varsto-placeholder (*.varsto-placeholder) with a hidden handler entry (double-click downloads and opens), Nautilus scripts (Scripts > Varsto > Download / Free up space), a Dolphin service menu and Nemo actions (Download / Free up space with Varsto); Thunar's custom actions live in one shared uca.xml and are not touched (add a custom action with the command varsto paths --notify fetch -- %F by hand). On Windows, varsto install registers per-user (HKCU) context-menu verbs on all files and the .varsto-placeholder association. varsto uninstall removes all of it. macOS uses Finder Services in the native app.

Selective sync and placeholders (F-039)

A folder mount can be selective. Files the device has not fetched exist as <name>.varsto-placeholder (a few bytes of JSON: size and time). Placeholders are never treated as deletions; fetch downloads one file and pins it so later updates are downloaded too; free replaces a local file with a placeholder, but only when every chunk is on a storage that is not a carrier (or on a replica). The interface lists files with their state and shows encrypted thumbnails.

Transferrer disks (F-048)

A local-directory storage can be marked carrier. The engine writes to it only chunks that no other device holds yet, and after every pull it deletes from the carrier the chunks that both this device and another device hold. Carriers are ignored by fsck's "claim without object" check and never count as the only durable copy for free.

Untrusted replicas (F-045)

A replica device holds a replica token (<vault-id>.<key>), where the key is derive(K3, "replica-ledger"). It mirrors every object from a source directory to a target directory, verifies chunk objects against their names, and records claims in its own ledger batches, sealed under the replica key with key_id = "replica" and signed with its own Ed25519 key. Its device record is published at vault/replicas/<device>.enc under the same key. Owner devices read those batches, fold the claims into the chunk records by object name (the replica cannot know chunk ids), and count them as verified copies. The replica never holds a key that opens content, names or manifests.

Shared folders (F-047)

A share token is <vault-id>.<folder-id>.<folder-key>.<name>. The recipient creates a member device: a device directory with a local random root key, the owner's vault id and that one folder record. Member records live at vault/shares/<folder>/<device>.enc under derive(folder key, "share-registry"); member batches are sealed per folder under derive(folder key, "share-ledger") with key_id = "share:<folder>". Owner devices and members resolve those keys from the folder key, so everyone holding the folder key sees everyone's claims for that folder and nothing else. Members cannot create folders, issue replica tokens or read the owner's device registry. Limitation: one device directory per shared vault; key exchange is manual. Confirming the requester, removing members and revoking tokens: section 24.

Encrypted thumbnails (F-046)

For image and video files, the device that holds the plaintext generates a JPEG thumbnail (longest side 256 px; videos through ffmpeg, found also outside a short PATH, or Quick Look (qlmanage) on macOS without ffmpeg; the Android and iOS apps make video frames natively and hand them in with POST /api/thumbnail), encrypts it under the folder metadata key with associated data (thumbnail, vault, folder, content hash) and stores it at thumbs/<folder>/<content hash>.enc on every hot, non-carrier storage. Any device with the folder key can show the preview, including for placeholders it never fetched. No thumbnail is written in clear text on disk.

Mobile shells

Android: a foreground service runs the same varsto service binary (shipped as libvarsto.so), the activity shows the local interface in a WebView. iOS: varsto-ffi exposes varsto_start and varsto_url for an in-process service behind a WKWebView; the Xcode project is generated with xcodegen on a Mac.

Folders kept encrypted on a device (block cache)

A folder mount may be marked encrypted in config.json (phones default to it). Such a device never writes a plain file of that folder:

  • Block cache. The device keeps the folder's encrypted objects, byte for byte as the storages hold them and under the same layout, in <device dir>/block-cache/chunks/<first two hex>/<object name>. Objects are content-addressed: an object is accepted only when the hash of its bytes is its name, and every fetched object is also decrypted in memory and checked against its chunk id before it is stored. Writes go through a temporary file and a rename. The folder directory stays empty.
  • State. In the folder state (state/<folder>.json), block_cache: true marks that local_index describes the cache: a path is in it exactly when every block of its current version is cached (state "local" in the file list; otherwise "placeholder"). No placeholder files are written.
  • Selective sync works as in any folder. Not selective: every current file is cached on each sync. Selective: new versions of files that are pinned (fetched) or already cached are cached, the others stay placeholders. Freeing a file (or a whole folder) removes it from the index and makes the folder selective; it is refused unless every block of the file is on a storage that is not a transferrer (or on a replica). After a sync and after freeing, cached objects that no cached file uses are deleted if a storage holds them; an object the ledger knows on no storage is kept (it may be the only copy), and one the ledger does not know at all is kept for an hour (an upload being encrypted).
  • Reading. The viewer, read_file and MCP read chunks from the cache first, then from peers and storages, decrypting in memory. On an explicit "open in another app" or "share", POST /api/export writes a decrypted copy to <device dir>/exports/<random>/<file name>; that directory is emptied when the vault locks and whenever the service starts. Locking deletes nothing else: everything else on the device is ciphertext.
  • Writing. POST /api/upload, write_file and the camera upload stream the content through the same content-defined chunker and deterministic chunk encryption as a push of a plain file (an existing chunk keeps its key epoch); only the resulting objects are written, to the cache, without holding the service state while the bytes arrive. The new version is then recorded, the objects are uploaded from the cache and the manifest is published. Moves record a tombstone and the moved version; mkdir writes nothing (folders exist through their files).
  • Peers. The block cache is served to peers as a local object root, so cached objects are answered verbatim.
  • Conversion. States without block_cache come from versions that decrypted such folders into their directory while unlocked. On the next sync while unlocked, every plain file found in the directory is encrypted into the cache and recorded (a file that differs from the synced version becomes a new version and is pushed), the state is saved, and only then is the plain file deleted; placeholder files and empty directories are removed and the index keeps only what is cached. While the vault is locked at service start, plain copies whose content is exactly the synced version are replaced by placeholders, as before.

11. Post-quantum hybrid cryptography (0.0.1-alpha.3)

Every place a public key is used now combines a classical and a post-quantum algorithm, so that stored data and signatures stay trustworthy if either is broken.

Signatures: Ed25519 + ML-DSA-65

  • A device signing key is an Ed25519 seed and an ML-DSA-65 (FIPS 204) seed, serialised as 64 bytes inside keys.enc. The public key is the Ed25519 key followed by the ML-DSA-65 encapsulation of the verifying key (32 + 1952 bytes) and is what the device record publishes; the device id is the keyed hash of these bytes.
  • A signature is the Ed25519 signature followed by the ML-DSA-65 signature over the same message (64 + 3309 bytes). sig_alg is ed25519+ml-dsa-65. Verification requires both parts; a batch whose post-quantum half is missing or damaged is rejected even if the Ed25519 half is valid.
  • Downgrade protection: a key that carries a post-quantum part never accepts sig_alg = "ed25519". Batches signed by alpha-0 devices (32-byte public keys, sig_alg = "ed25519") remain valid for those devices; such a device keeps signing Ed25519-only until it is re-enrolled, because changing its key would change its device id. No stored object had to be rewritten: the algorithm identifier on every batch selects the verification rule.
  • Cost: about 3.3 KB per batch. Batches already group many events (model B), so the ledger grows by a few kilobytes per sync, not per file.

Key encapsulation: X25519 + ML-KEM-768

  • Used wherever a secret must reach another party's public key. Today that is the share token: the recipient runs varsto share request, which stores a private key in its device directory and prints a request code vsr1.<X25519 key || ML-KEM-768 key> (1216 bytes, hex). The owner runs share create <folder> --to <code>.
  • The owner encapsulates to both halves, derives one key with derive_key("e2ee-sync-format/0/hybrid-kem/x25519+ml-kem-768", len-prefixed(x25519_ss, mlkem_ss, ciphertext, encapsulation_key)), and encrypts the folder key with XChaCha20-Poly1305 under associated data (share-token, vault id, folder id, name). The sealed token vst1.<vault>.<folder>.<kem-alg>.<kem-ct>.<sealed-key>.<name> carries only ciphertext and can travel over any channel; only the requesting device can open it. The plain token format of alpha.2 still exists and is marked as carrying the key.
  • Confirming that a request code came from the intended person (a six-word fingerprint both sides compare), version-2 tokens, removing members and revoking tokens are in section 24. QR codes do not exist.

12. Remote storages: S3-compatible buckets and rclone remotes (0.0.1-alpha.3)

The storage abstraction (put-if-absent, get, exists, list, delete) now has three backends. Every object is ciphertext before it reaches any of them; a storage learns object names and sizes only.

  • S3-compatible (varsto storage add-s3): plain HTTPS with AWS Signature Version 4, no SDK. Works with AWS S3, Scaleway, Hetzner Object Storage, Backblaze B2, Cloudflare R2, MinIO and rclone serve s3. Path-style URLs by default (--virtual-host for bucket.host). Writes check for existence and then PUT with If-None-Match: *, so an existing object is never overwritten even on servers that ignore the condition. Listing uses ListObjectsV2 with continuation. A storage class (--storage-class DEEP_ARCHIVE, GLACIER_IR, ...) is sent with every PUT; archive classes mark the storage cold automatically, and a GET that returns InvalidObjectState is reported as "must be restored first", never retried silently. The secret access key is kept in secrets.enc (XChaCha20-Poly1305 under derive(master, "storage-secrets"), mode 0600), never in config.json; VARSTO_S3_SECRET_<NAME> is the fallback for scripts.
  • rclone (varsto storage add-rclone <name> remote:path): runs the user's own rclone (rcat, cat, lsjson --stat, lsf -R, deletefile). Credentials stay in rclone's configuration; Varsto never reads them. Any of rclone's backends (SFTP, WebDAV, Google Drive, OneDrive, Storage Box, ...) becomes a Varsto storage this way.
  • Local directory: unchanged.

Tests: the S3 backend is exercised against rclone serve s3 (a local S3 server with access keys) and the rclone backend against a local-path remote, both through the full two-device sync, in crates/varsto-core/tests/remote_storages.rs. They skip when rclone is not installed.

13. Last-accessed times and the MCP server (0.0.1-alpha.3)

Last accessed. Each device keeps its own record of when a file was last used through Varsto: fetching a placeholder, opening or downloading it from the interface, reading it through the MCP server, and the filesystem access time observed at scan (never older than the modification time). The record lives in the per-folder state (state/<folder>.json, field accessed). last_accessed_utc (reported by varsto folder files --json, /api/files and the MCP file list, and used by the idle-file advice) is the newest use on any device of the folder, merged as described below.

Access records (0.0.1-alpha.9). Every device publishes its map as one object per folder, vault/access/<folder>/<device>/<written utc, 20 digits>.enc: zstd-compressed JSON {device, folder, written_utc, accessed: {path: utc}}, sealed with XChaCha20-Poly1305 under derive(current metadata key of the folder, "access-record", folder) with associated data (access-record, vault, folder, device, written_utc). After a sync, at most once an hour (ACCESS_INTERVAL_SECS), a device writes a new record for every attached folder whose map changed and deletes its older ones, then lists the folder's records and reads the newest of every other device that is newer than the one it holds (state/access/<folder>.json). Records of devices that are not known (vault registry or folder members) or that were removed are ignored; locked Strongrooms are skipped. The merged time of a path is the maximum over this device and the others, so "idle for 90 days" means idle on every device. Reasons for an object rather than ledger events: access times change with every use, while the ledger is append-only and replicated forever (checkpoints bound its replay, not its history); one replaceable object per device and folder costs O(files) bytes no matter how often files are used, and the ledger format does not change. Limits: an hour's delay; a member of a shared folder contributes its use too (it is a device of the folder); a path moved on one device keeps its old name in the others' records until they use it again; the times are hints for advice and are not signed.

MCP server. varsto mcp speaks the Model Context Protocol (JSON-RPC 2.0, newline-delimited, over stdio; protocol version 2025-06-18) so that an assistant of the user's choice can be pointed at it. Access is explicit: nothing is visible until the user runs varsto mcp grant <folder> (read-only) or varsto mcp grant <folder> --write; all grants every folder; grants are stored in mcp-grants.json and can be revoked. Tools: varsto_status, varsto_folders, varsto_files, varsto_read (text up to 512 KiB; fetches placeholders; counts as access), varsto_move, varsto_mkdir and varsto_write (reorganise and leave notes or indexes; read-write grant only; changes sync), varsto_sync, and varsto_storage_advice, which exposes Varsto's own placement estimate (files idle for N days and their monthly cost in every storage class of the open price data set in data/providers, embedded at build time, each figure with its source URL and verification date). The intended use of the server is reading, analysing and reorganising files; placement advice is a feature of Varsto itself (interface and command line), merely visible to the assistant. When the background service is running the server routes calls through its local API so one process owns the state; otherwise it opens the vault with VARSTO_PASSPHRASE. Strongroom folders will be excluded from grants by default.

Interface: files now show "last used" and an Open link; /api/open downloads a file and records the access; /api/move and /api/advice back the same operations.

14. Durability policies with alerts (0.0.1-alpha.3, F-032)

A policy belongs to a folder: a minimum number of copies on any storage (transferrers never count), a minimum per place, and a window within which every chunk must have been verified by a device other than the one that wrote it. Places are labels on storages (--place when adding one; a directory defaults to home, a bucket or rclone remote to cloud; replicas count as replica, storages this device does not know as other).

Policies travel with the folder: varsto policy set writes the folder record locally and publishes an append-only policy record at vault/policies/<folder>/<device>/<updated_utc>.enc under the folder record key; every device adopts the newest it can read. Evaluation (varsto policy check, the interface, the service after every sync) uses the ledger alone: for every chunk of every current file, the storages that claim it, whether an independent verification exists and when (batches now carry claim and verification times). States: ok; at_risk when a verification is older than three quarters of the window, or the copy count is met exactly and one copy sits on cold storage or absent media; violated when a rule is broken; unknown when a storage could not be opened. varsto policy check exits 0, 1, 2 or 3 accordingly, so a cron job or a monitoring probe can use it. The service raises a desktop notification (notify-send on Linux, Notification Center on macOS) when a folder's state gets worse, and the interface shows the state next to the folder and a banner on top.

Honest limits: verification comes from another device's automatic verification (section 20), from varsto fsck --verify or a replica run, and from downloads during pull. Place labels are per device: two devices should label storages the same way, which the registry does not enforce yet; storage names may differ between devices for automatic verification, which matches storages by identity.

15. Peer-to-peer transfer (0.0.1-alpha.4; NAT traversal 0.0.1-alpha.6)

Every device that holds a file can serve its chunks as exactly the objects the storages hold, because chunk keys and nonces are deterministic per folder: the service keeps a snapshot (folder keys plus an index object name -> file, offset, length, rebuilt after every sync) and re-encrypts a piece on request, or serves it verbatim from a local-directory storage. A device that needs a chunk asks its peers or its (non-cold) storages, verifies every object against its content-addressed name (the same check as for storage copies), and falls back to the other source when the first lacks the object; how it chooses is under Downloading below. PullReport.chunks_from_peers and the service counter show how much came from peers.

Transport: two transports share one request handler and one port number. Peer-to-peer is on by default on every platform (TCP and UDP port 17893; varsto p2p disable turns it off, and enable or disable take effect in the running service at once). HTTP over TCP serves the LAN and user-forwarded ports as before; QUIC over UDP on the same number (0.0.1-alpha.6) is the transport that crosses NATs. Objects are ciphertext, so nothing further is encrypted on the wire for its own sake; every request carries X-Varsto-Peer: <device>:<unix time>:<keyed hash> (the QUIC line X-Varsto-Auth), where the hash is keyed_hash(derive(master, "p2p-auth"), device || time || path), valid for five minutes, so only a holder of the vault key can fetch and a listener learns object names only. Members of shared folders are not peers yet (they lack the master key).

Downloading (0.0.1-alpha.9): requests run side by side in a window that starts at 16 and moves between 8 and 32 in steps of 4 by hill climbing on throughput: the bytes per second of each round (at least half a second and 8 objects) are compared with the previous round's; a gain keeps the window moving the same way, a loss of more than 5 % turns it round. (Answer times cannot steer it: with fewer requests in flight, kept connections idle long enough for TCP to restart slowly, so answers get slower.) Each object goes to the peers or to the storages: peers first while the storages have no sample (every 16th object asks the storages, to get one); then each source gets a share of the objects in proportion to its speed (mean seconds per object), the peers' speed counted 1.25 times, since peer-to-peer costs nothing per gigabyte. A source that lacks objects rests: the storages after 2 misses in a row for 30 s (a bucket whose blocks were moved away), the peers after 4 for 10 s; statistics older than two minutes are dropped. So with peer-to-peer on, a download uses both sources instead of waiting on the slower one. Over TCP the client keeps connections to each peer address open between requests (HTTP/1.1 keep-alive, up to 32 per address, closed after 20 s unused; a kept connection that fails is replaced and the request asked again once); the listener sets TCP keep-alive (60 s, then every 15 s), which accepted connections inherit, so a peer that vanished is noticed. Peers of earlier versions close the connection after each request, which the client handles as before. The server is a small HTTP/1.1 server of its own (GET only): a thread per connection, at most 256 connections, one idle for 30 s is closed; producing objects is limited to twice as many at a time as there are cores (8 to 32). It produces a requested object by first encrypting the block uncompressed and checking the name, which matches for incompressible blocks and saves compressing them again, and otherwise compresses as at upload; objects it had to compress are kept in memory for other devices asking for the same blocks (VARSTO_P2P_CACHE_MB, default 64, 0 off).

The UDP socket is driven through quinn-udp's socket state so that every datagram carries its addresses: a server bound to 0.0.0.0 on a multi-homed host (a cloud machine with a private network, LAN plus VPN, Docker bridges) answers from the address the request arrived at (IP_PKTINFO in, source address out), as TCP does by itself. Without this the kernel picks the source by its routing table and the client's QUIC stack drops the answer as coming from an unknown remote.

QUIC identity: the service creates, on its first start, a self-signed ECDSA P-256 certificate per device (p2p-cert.der, p2p-key.der in the device directory, with quinn, rustls with the ring provider and rcgen); its SHA-256 travels in the peer record. A client pins the hash the record announced (a custom server-certificate verifier; names and dates play no part) and presents its own certificate; the server demands one and accepts only hashes of devices whose records it has read, plus its own. A stranger, or a vault device whose record has not arrived yet, fails the handshake; the request header above remains as a second layer behind it. ALPN is varsto-p2p/1. Framing is one bidirectional stream per request: GET <path>\nX-Varsto-Auth: <token>\n\n, answered by <status> <length>\n and the body; REGISTER <device>\n... on a stream makes the connection a relay registration (below).

Discovery: on the LAN a multicast beacon (239.255.77.77:17892, VARSTO1 <tag> <device> <port>, where the tag is a keyed hash of the vault id, so beacons cannot be linked to a vault) every five seconds; across the internet a rendezvous record vault/peers/<device>.enc, encrypted under the device registry key. Record version 1 (alpha.6; version 0 records without the new fields are still read) carries version, device, name, port, lan_addrs, public_addrs (what the user configured with --public), updated_utc, and the traversal fields: udp_public (addresses STUN servers saw for our UDP socket), udp_local (the interface addresses with the UDP port), cert_sha256, nat (none, cone, symmetric or unknown), relay_via (reachable devices this one holds a relay registration with) and reachable (a public address answered the device's own probe, or the user configured one). The service republishes the record when any field other than the timestamp changed, and once an hour regardless.

STUN: the service asks the servers in p2p.stun (default stun.l.google.com:19302, stun.cloudflare.com:3478, stun.nextcloud.com:443; an empty list disables it) with an RFC 5389 Binding Request from the QUIC socket itself at start and every ten minutes, reading XOR-MAPPED-ADDRESS (MAPPED-ADDRESS as fallback, IPv4 and IPv6). Two servers agreeing on the mapped port means a cone NAT, disagreeing a symmetric one, a mapped address that is one of our own means no NAT, fewer than two answers no guess. The guess is recorded as such: it cannot see every NAT behaviour. The STUN servers only ever see an empty Binding Request from the UDP port; nothing about the vault.

Hole punching: when fetching from a peer, the client tries the route that worked last time first, then in order (a) its TCP addresses (LAN beacon, lan_addrs, public_addrs), (b) QUIC to its udp_public, public_addrs and udp_local addresses while sending an 8-byte punch datagram (VARSTOPU) every quarter second for up to three seconds (LAN addresses get no punches and a short wait), (c) a relay. A keeper task punches toward every known peer's public UDP address every twenty seconds, which keeps our own NAT mapping alive and opens a path for peers that try to reach us. The outcome per peer is remembered: a dead peer costs one round of timeouts per minute. A storage error while listing records keeps the last good list, because an empty peer table would stop the punches (letting the mapping lapse) and make the QUIC server refuse every peer's certificate. The service asks STUN again (at most every 30 s, otherwise every ten minutes) when a punched connect failed or the keeper paused for longer than a mapping survives, since the mapping may have changed in the meantime. Every path decision, punch set, QUIC connect and relay step is one p2p: line in the service log.

Relay: every device whose record says reachable relays for its vault; there is no vendor relay. A device that is not reachable opens a QUIC connection to each reachable peer at service start (a failed attempt is retried after 30 s, then with doubling waits up to five minutes; a lost registration is retried at once, and a success republishes the record immediately so peers learn relay_via), sends REGISTER <device> on the first stream (the relay checks the token for /p2p/register/<device> and that the client certificate is the one that device's record announces), and from then on accepts the streams the relay opens on that connection and serves them exactly like incoming requests; QUIC keep-alives hold the NAT mapping. The relay answers GET /p2p/via/<device>/<rest> by checking the token for /<rest>, opening a stream on that device's registration connection, forwarding the request and copying the answer back (502 when nobody is registered under that device). A fetcher picks the relay from the target's relay_via. The relay sees ciphertext objects and object names, nothing else, and it is one of the user's own devices.

Status: varsto p2p status and GET /api/p2p report the NAT guess, the observed public address, whether the device is reachable, the relays it is registered with, and per peer the path in use or last tried (direct-lan, direct, relayed via <name>, unreachable, or untried); the Peers page shows the same. Without a running service the command probes over TCP and asks STUN from a temporary socket, whose mapping differs from the service's.

Traffic: the service counts, per peer and direction, the object bodies it received and sent (bytes, objects, requests in flight, path and address of the latest exchange) over every transport, and what it forwarded between two other devices as their relay. GET /api/p2p/traffic and varsto p2p traffic [--watch] report current speeds (average over the last 5 seconds), totals since the service started and one-second samples of the last minute; the Peers page draws them as a flow diagram. The endpoint is answered by the HTTP dispatcher without the service state, so it stays live during a long sync. Nothing of this goes over the wire.

Honest limits: tested on localhost (QUIC with pinned certificates, rejection of an unknown certificate, a relayed fetch A -> R -> B, the multi-homed reply source), on the maintainer's own networks, and on a rig of three devices (a public cloud machine as relay, a device behind a Linux MASQUERADE NAT on it, a device behind a home NAT) in both directions, direct and with direct UDP blocked (relayed). A Linux NAT keeps one external port for all flows of an internal socket until a peer's punch arrives while no flow of ours is alive (service start, a pause): then the port is in use and the NAT picks a random one for every later flow, so the published udp_public is stale until the next STUN probe, which is why probes are repeated on suspicion. Symmetric NATs on both sides are not punchable and fall back to the relay, which needs one reachable device in the vault (a home server, a VPS, a forwarded port); a vault whose devices are all behind NAT has no path across the internet. There is no DHT and no packaged self-hosted relay for vaults without a reachable device; IPv6 is parsed but the sockets are IPv4; port-restricted and symmetric cases are guessed, not measured with a second port.

16. Compression (0.0.1-alpha.4, format version 1)

Ciphertext does not compress, so compression happens once, before encryption, on the chunk: the payload is one method byte (0 raw, 1 zstd) followed by the data. zstd level 12 is a format constant, because every device must produce byte-identical objects for the same chunk or deduplication and content addressing would break; data that would save less than 5 % is stored raw, so photos and video cost one byte. The gain reaches every copy and every transfer alike: cloud, disks, transferrers and peers on the LAN or across the internet, with no transport-level compression to tune. Format version 0 objects are not readable by this version (see format-versions.md).

17. Strongroom folders (0.0.1-alpha.4, S-012)

A Strongroom folder's key is never at rest anywhere: the keyring and the published folder record carry an empty key and a strongroom block (method, credential id, 32-byte salt, the folder key wrapped under derive_key(context, hmac_secret) with XChaCha20-Poly1305 and the folder id as associated data). The secret comes from the FIDO2 hmac-secret extension (credential and salt in, 32 bytes out, after a touch), so the same physical key opens the folder on every device of the vault, and nothing derivable from the vault's master key does. Enforcement is cryptographic: without the unwrapped key no manifest, name or chunk of the folder can be read.

Backends: fido2 runs the libfido2 command-line tools (fido2-cred -M -h, fido2-assert -G -h), which exist for Linux, macOS and Windows, so the core carries no native HID dependency; software keeps the secret in a file and exists to try the flow and for the tests (it protects nothing beyond the passphrase and says so). Enrolling takes two touches (credential, then wrap); unlocking one.

Behaviour: varsto strongroom create makes a new folder, and varsto strongroom convert turns an existing one into a Strongroom (below). Strongroom mounts are selective, so files are placeholders until fetched. An unlock holds the key in the process memory for a window (default 15 minutes) and hands it to the running background service over the local API so the service can sync; when the window ends the key is forgotten and sync skips the folder. Locked folders are skipped by sync, refused by direct operations, never shared, and never visible to the MCP server.

Honest limits: files fetched while unlocked stay on disk until freed (the interface reminds you); malware active during the window sees what you see; the libfido2 tools prompt for the PIN on the terminal, so the first unlock happens on the command line even when the interface is used afterwards; a lost key with no backup key means a lost folder (enrol a backup key before trusting it with anything); the recovery kit does not cover Strongroom folders.

Backup keys. A Strongroom can have several security keys. Each enrolled credential has its own salt and its own wrap of the same folder key; the first key stays in the top-level fields of the strongroom block (so records written before backup keys read unchanged) and the others are listed in backups (method, credential, salt, wrapped key, optional label, time added), with updated_utc stamping the list. Unlocking tries every enrolled key in turn. varsto strongroom add-key <folder> needs one touch of an enrolled key (to unwrap the folder key) and two of the new one; strongroom keys lists them; strongroom remove-key <folder> <number|label|credential> refuses to remove the last one. A changed list is published as vault/strongroom-keys/<folder>/<updated_utc>.enc (sealed under the folder-record key, newest wins on every device); older lists are deleted and the folder record is rewritten with the current list, so devices that join later see it too. Removing a key removes its wrap from the storages but does not change the folder key: a removed key that someone kept, together with an older copy of the records (on a device or in a backup of a storage), still opens the folder until the Strongroom is re-keyed (below).

Re-keying (0.0.1-alpha.9). varsto strongroom rekey <folder> rotates the folder key, for example after removing a key someone may have kept or on suspicion of a leak. It is the conversion below, from a Strongroom to a Strongroom: the command line unlocks the folder with an enrolled key (one touch), makes a new folder id and a new random key, and wraps the key for every enrolled security key with a fresh salt (strongroom::rewrap_enrolled, one touch each; a key that does not answer stops the command, so remove a key that is gone first). The rest runs in the background service when one is running (POST /api/strongroom/rekey {folder, folder_id, key_hex, old_key_hex, info, minutes}), otherwise in the command: every current file is re-encrypted under the new key, the conversion record (now with rekey: true) is published, the device switches, and the old chunks, manifests, thumbnails, policy records, folder record and key lists (vault/strongroom-keys/<old folder>/) are deleted from every storage. The journal keeps only the old record without a key, so no key is at rest during a re-key; an interruption resumes with the same command (one touch for the old key, one for the new). Other devices adopt it like a conversion: the mount moves to the new folder, locked, and synced plain copies become placeholders. The interface shows the command next to the enrolled keys. Limits: the same as for a conversion (copies made earlier, local trash, replicas and storage backups are not reached); every enrolled key must be present.

Converting an existing folder. varsto strongroom convert <folder> (two touches; with a running background service the command line touches the key and the service does the rest over the local API, POST /api/strongroom/convert). The converted folder gets a new folder id and a new random key, so its chunk ids, objects, manifests and ledger facts never mix with the old ones. Steps, each safe to repeat:

  1. The conversion is journalled in the device's encrypted keyring (old folder record, new id, the new key's wraps); the folder is pulled and pushed under its old key. It is refused while versions from other devices could not be downloaded, and for shared folders.
  2. Every current file is re-encrypted under the new key: from the local copy when it is the synced version, otherwise downloaded under the old key first (placeholders). The old keyed content hash is checked while reading. Each chunk is put on every storage that takes it; every chunk must be on at least one non-carrier storage. The manifest of the new folder is published and the ledger batch committed.
  3. A conversion record vault/converted/<old folder>.enc (sealed under the folder-record key; old and new id, converting device, and the highest manifest sequence of each device that the converted copy includes) is published, then the device switches: the keyring holds the new Strongroom record (empty key), the mount points at the new folder and becomes selective, the key stays unlocked for the chosen window, and the new folder record is published.
  4. The old copies are deleted from every storage of the device, cold ones included: the chunk objects the ledger and the old manifests name, every old manifest, thumbnails, policy records and the old folder record (which held the old key under the vault key). Only when every storage is clean does the journal, and with it the old key, leave the keyring; otherwise the clean-up is retried on every sync.

An interruption before step 3 leaves the old folder exactly as it was (new objects may already exist; they are reused on resume); running the command again resumes under the same new key after one touch. An interruption after step 3 is finished by the next sync.

Other devices adopt the conversion on their next sync, once both the conversion record and the new folder record are readable: the mount moves to the new folder (selective, locked), plain copies whose content is the synced version are replaced with placeholders, the old key is dropped from the keyring after the device has removed the old copies from its own storages (disk pools are per device). Files changed there since the converted copy was made stay as they are; after the next unlock they are synced like new local files (identical content merges, different content becomes a conflict copy, a deletion made after the snapshot is undone). If the device published changes after the converted copy was made, none of its plain copies are removed. Limits: a device that already had the old key could have kept it, and anything it copied stays readable to whoever holds it; files in a device's local trash, replica devices and backups of a storage are not reached; the ledger keeps its signed facts about the old folder (chunk ids and object names, no content).

18. Recovery kit (0.0.1-alpha.5, F-042)

varsto recovery kit prints the vault key as 24 BIP-39 English words (256 bits plus the standard 8-bit checksum, so a miscopied word is detected) in a printable layout with the vault id and the rules: paper or metal, several places, never a photo or a cloud note, test yearly. With --shares it adds three Shamir shares over GF(256) (any two rebuild the key; one reveals nothing), each as an index and 24 words; varsto recovery combine "<index>: <words>" "<index>: <words>" rebuilds the key, and varsto join --words "..." enrols a device from the words directly. Only an unlocked owner device can print the kit; member devices hold folder keys only. SLIP-39-compatible shares and QR codes are not provided: words are what survives on paper.

19. Disk pool (0.0.1-alpha.6, plan 6.35 and 6.41)

A pool is one storage made of removable disks that are attached and detached over time. It holds chunk objects only: records, ledger batches, manifests and thumbnails go to the other storages, so a pool is never a device's only storage. The program never formats, mounts or unmounts a disk.

Disk layout. A disk is a directory (its mount point) with two things of Varsto's:

<mount>/.varsto-disk.json      marker: { pool_id, disk_id, label, vault_tag, created_utc, format: 1 }
<mount>/varsto/chunks/<xx>/<object name>   encrypted chunks, the local-directory layout of section 5
<mount>/varsto/index.json      the disk's own index: { format, pool_id, disk_id, label, written_utc, objects: { key: size } }

pool_id is keyed_hash(derive(K3, "disk-pool"), "pool:<name>")[..16] and vault_tag is keyed_hash(derive(K3, "disk-pool"), "vault:<vault id>")[..16]: both are computed from the vault key, so every device of the vault derives the same pool id for the same pool name and recognises its disks, while the marker reveals neither the vault id nor the pool name. disk_id is 16 random bytes. Objects are written to a temporary name, fsynced and renamed, exactly as in a local-directory storage.

Device state. <home>/pool-<pool id>.json maps every object key to the disk that holds it (with its size), keeps deletions queued for disks that were away (pending_deletes, per disk) and the disk registry: id, label, last mount path, capacity, bytes used, last seen, last verified, retired. config.json carries the pool (kind: "pool": name, place, reserve_percent default 5, min_reserve_bytes default 2 GiB, optional scan_roots) and mirrors the registry. The pool index is per device, like the rest of config.json; a disk filled by another device is adopted when it is first seen attached: its marker names the pool, its tag matches the vault, and its index.json tells what it holds.

Attach detection. A disk is attached when a directory with its marker is found at its last mount path or by scanning the platform's mount roots: /media/*/*, /run/media/*/* and /mnt/* on Linux, /Volumes/* on macOS, every drive letter on Windows, plus the pool's scan_roots. Scanning reads one small file per candidate directory. The background service checks every 30 seconds and logs and notifies "data-01 attached" / "data-01 detached"; an attach asks for a sync, so files waiting for the disk arrive.

Behaviour as a storage. put picks the attached, non-retired disk with the most free space whose free space after the write stays above max(reserve_percent × capacity, min_reserve_bytes); with no disk attached (or no room) the pool is left out of that push and tried again on the next one (PushReport.storages_unavailable). get reads the disk if attached; otherwise it fails with a typed NeedsDisk { label, disk_id, place } error, which pull reports as attach disk <label> (<place>) (PullReport.disks_needed, and the file stays in pending_remote), /api/fetch returns as {"needs_disk": {"label", "disk_id", "place"}}, and the interface shows as "This file is on disk

Life cycle. varsto storage add-pool <name> --place <place> [--reserve-percent N] [--scan-root <dir>] creates the pool. varsto disk add <mount-path> --pool <name> --label <label> writes the marker and the disk index, registers the disk and fills it with every object of a current file that the pool holds on no disk, largest first, within the reserve; objects come from the other hot storages or are re-encrypted from local files, and no other storage is changed. varsto disk list shows every disk: attached (mount, free space) or offline, blocks and bytes used, last verified, pending deletes, retired. varsto disk check <label> [--full] is the reattach routine: verify the marker, apply the queued deletions, verify every object's size (--full: re-hash it; a full check also publishes chunk_verified events), drop bad objects from the index, adopt objects on the disk the index did not know, then fill with new objects; it prints checked 3.2 GB, 0 bad, 410 MB removed, 620 MB added. varsto disk eject <label> writes the disk's index, syncs the directories and prints "safe to remove"; unmounting is the user's. varsto disk retire <label> marks the disk so nothing new goes there and prints how many objects exist only on it. The local API has the same operations: GET /api/disks, POST /api/disk/add {mount, pool, label}, POST /api/disk/check {label, full}, POST /api/disk/eject {label}, POST /api/disk/retire {label}, and POST /api/storage {kind: "pool", name, place, reserve_percent}.

Not done. Disk groups (several copies on disks in different places) came in 0.0.1-alpha.9, section 23; no SMART or health reading; no import of git-annex repositories; no transfer-time estimate in the "attach this disk" prompt; the service does not run disk check by itself on attach; a disk unplugged in the middle of a write fails that push instead of being retried; a disk's index.json is rewritten whole, so very large disks pay for it at every eject; members of shared folders cannot use pools (they lack the vault key the pool id is derived from).

20. Automatic verification, storage prices and placement (0.0.1-alpha.8)

Automatic verification (F-032). The background service verifies blocks that other devices wrote, so a policy's "verified by another device within N days" stays true without anyone running fsck --verify by hand. After a sync, when the schedule says a run is due, the device pulls the ledger and lists every copy (folder, chunk, storage) that another device claimed and that this device can read without asking: cold storages and transferrers are never read, replicas are not storages of this device, and a copy on a pool disk that is away is counted as offline. A copy is due when no other device has verified it, at half the folder's verified_within_days window after its last independent verification (well before the policy turns "at risk" at three quarters), or after reverify_days (default 30) in folders without a window. Due copies are taken oldest deadline first, folders with a window first on ties, until the budget of the run is spent (default 2000 blocks or 256 MiB; the first block is always taken). Each one is downloaded and its ciphertext hashed against its object name; a match is recorded as chunk_verified, the same event fsck --verify and pull record, and a mismatch or a missing object is reported, raises a desktop notification and is queued for automatic repair (section 26). Schedule and budget are per device in config.json (verify: enabled default true, interval_hours default 24, max_bytes, max_blocks, reverify_days); the last run is kept in state/auto-verify.json. Nothing runs while the service is paused or locked.

Storage names are local: one device may call a bucket "box" and another "primary", and the ledger names a copy by its writer's name. Every storage therefore gets a random identity object, vault/storage-id.json, written with put-if-absent by the first device that needs it (the first writer wins), and every device publishes its names with their identities at vault/storage-names/<device>/<utc>.enc, sealed under the device registry key; readers take the newest per device. A copy the writer calls "box" is read from the local storage with the same identity and the check is recorded under the writer's name, so it meets the writer's claim. A writer that has not published its names yet (an older version) is matched by equal name.

Commands: varsto verify (schedule, last and next run), varsto verify run (one run now, whatever the schedule says; exit code 1 on a corrupt or missing object), varsto verify set [--on|--off] [--interval-hours N] [--max-mib N] [--max-blocks N] [--reverify-days N]. API: GET /api/verify, POST /api/verify {enabled, interval_hours, max_mib, max_blocks}, POST /api/verify/run; GET /api/service carries auto_verify and a one-line auto_verify_text, which varsto service status prints. The interface shows the status on the Policies page with Run now, Schedule and Turn off/on.

Storage prices. Each storage can have a price: storage per GB-month, egress per GB, retrieval per GB and a minimum storage duration for cold classes, in one currency. The user's figures live in config.json (prices, by storage name); a figure the user did not set comes from the built-in price data (data/providers) when an S3 storage's endpoint, region and class are recognised (Amazon S3 in eu-central-1 and Scaleway in fr-par today, the regions the data was verified for), with the source and verification date. Unknown means unknown, never free. The monthly cost of a storage is the bytes the ledger records on it times its price; a folder's cost is the sum over the storages that hold its current files, kept per currency and never converted. varsto storage price <name> [--gb-month X] [--egress X] [--retrieval X] [--min-days N] [--currency EUR] [--clear] shows or sets the figures, varsto storage list shows price, stored bytes and monthly cost, GET /api/storage/costs and POST /api/storage/price {name, gb_month, egress, retrieval, min_days, currency, clear} back the Price button on the Storages page, and varsto advice and /api/advice include the storages and per-folder costs.

Placement. In this release Varsto wrote every block to every storage, so the placement change it could carry out was freeing idle files on this device (section 23 adds per-folder placement and moving blocks between storages): a suggestion free-idle:<folder> lists a folder's files that have not been used for the idle threshold (default 90 days), are on this device and are not pinned, with the storages that keep them (cheapest first by the price model), what that costs per month, what reading them back would cost, and the disk space it frees. The storage bill does not change and the confirmation says so. varsto advice apply <id> (asks for confirmation unless --yes), POST /api/advice/apply {id, idle_days} and the button on the suggestion in the interface carry it out: the folder is pushed first, then a file's local copy is replaced by a placeholder only when every block has a copy on a storage this device reads without confirmation (cold storages and transferrers do not count), the folder's copy rules (min_copies, per place) still hold for the file from the ledger, and the file on disk is the synced version (free_file). A block whose readable copies nobody has verified yet is downloaded and hash-checked first and the check is recorded. Files that cannot go stay and are listed with the reason.

Not done. Moving data between storages came in 0.0.1-alpha.9 (section 23). Prices are per device and not synced. The built-in data covers two providers and one region each; rclone remotes, directories and pools have no built-in price.

21. Removing a device: revocation, key epochs and remote wipe (0.0.1-alpha.8)

varsto device revoke <name|id> [--wipe], or "Remove device…" under Devices on the Peers page, removes a lost or stolen full device from the vault from any other full device. It implements section 6 of key-hierarchy.md for this format, with the differences listed at the end.

Why a new vault key. Every full device holds the vault key, and the ledger, registry, folder-record and peer-authentication keys are derived from it; folder keys are published under it. Taking a device out therefore needs a vault key the removed device never sees. The vault key now has epochs: epoch 0 is the key the vault was created with (the one in the recovery kit of that time), and each revocation starts the next epoch with a new random key.

Objects (new, under vault/):

Object Content Protection
vault/kem/<device>.json the device's hybrid KEM public key (X25519 + ML-KEM-768, the construction of section 11), published by every full device with its records and on its first sync signed by the device key; checked against the device record
vault/revocations/<device>/<issuer>.json the removed device and its name, the issuer, the time, the ledger cut-off (the removed device's newest batch the issuer held), the wipe flag and the new epoch body encrypted under the epoch-0 registry key, so every device that ever belonged to the vault, the removed one included, can read it; signed by the issuer over the vault, both device ids, the key epoch and the ciphertext hash
vault/epochs/<epoch>.enc the previous vault key, the issuer, the time, the full devices that stay, the removed device, the folders that keep their key XChaCha20-Poly1305 under derive(K_epoch, "epoch-record"), associated data vault and epoch: only holders of the new key can read or write it, and the chain of previous keys lets the current key open every older epoch
vault/grants/<epoch>/<device>/<issuer>.json the epoch's vault key, sealed to one device hybrid KEM to the device's KEM key, AEAD with vault, epoch, device and issuer as associated data, signed by the issuer

On the device, vault-keys.enc (encrypted under a key derived from the device's own root key, like keyring.enc) holds the vault key of every epoch the device knows, its KEM private key, the member list of the newest epoch and the folders that keep their key.

Revoking (device A removes C). A pulls the registry and the ledger, then checks the guard rails: C is a current full device of the vault, it is not A itself (a device resets itself instead), it is not the last full device, and no newer epoch than A's exists on the storages. A writes the epoch record with put-if-absent (two devices rotating at the same instant through the same first storage collide there and the second gives up), writes the signed revocation, adopts the new key and seals it to every remaining device that has published a KEM record. A device that has not (it has not run this version yet) is listed as pending; any device of the new epoch seals the key to it on its first sync after it publishes one.

On every pull a full device, before it ingests ledger batches: publishes its KEM record where it is missing; reads revocations and accepts those that verify against a trusted device (oldest first, so a device revoked earlier cannot revoke anyone later); adopts the newest epoch whose grant is signed by a trusted device, opens with its KEM key, has a chain of epoch records that leads back to the key the device holds, and whose member list names the device; and seals the current key to members that lack a valid grant. "Trusted" means a known full device that is not revoked and that the newest epoch kept, or whose device record opens only under the current key (it joined later).

Effects:

  • Ledger. Batches of the removed device with a sequence number above the cut-off are not ingested, and are left out of the ledger view if they were stored before the revocation arrived. Its manifests count only up to the last one its accepted batches announced, so files it writes afterwards never reach other devices. New batches are sealed under the newest epoch's ledger key with key id ledger@<epoch> (epoch 0 keeps ledger).
  • Registry. Device, folder, policy and rendezvous records are written under the newest epoch's keys and read with the key of any epoch. A device record that opens only under an older key, of a device the newest epoch does not list, is not trusted: a device enrolled with an out-of-date vault key (the removed device joining again under another name, or someone with an old recovery kit) cannot sign revocations, grants or ledger batches that others accept. join with an out-of-date key is refused outright, with a message that asks for the current key.
  • Peers. Revoked devices are dropped from the peer list: their rendezvous records and LAN beacons are ignored, so the QUIC server stops accepting their certificate; the request handler refuses tokens that name them; and the peer authentication key is derived from the newest epoch, so the removed device cannot make a valid token for a device that has adopted it. A device re-seals its rendezvous record under the new registry key when it adopts an epoch, so its addresses stop being readable to the removed device at once.
  • Folders. The folder key of epoch e >= 1 is derive(K_e, "folder-epoch", vault, folder). New chunks, manifests and thumbnails use the newest epoch; a chunk reference in a manifest records its epoch (epoch, omitted when 0, so older manifests are unchanged), and manifests and thumbnails are opened with the newest metadata key that fits. The keyed-hash key stays that of epoch 0, so chunk ids and content hashes do not change: a file that did not change keeps its objects and is not uploaded again, and deduplication and merging work across epochs. A chunk the folder already references keeps its object even inside an edited file. Folders shared with other people and Strongroom folders are frozen: they keep writing under their epoch-0 key (or a shared folder's newest share epoch, section 24), because members of a shared folder hold only the keys they were given and a Strongroom key never comes from the vault key. Sharing a folder that was re-keyed works since 0.0.1-alpha.9: the folder first gets a share epoch, and the token carries its vault-epoch folder keys so the member reads the older data.
  • The removed device. When it next reaches a storage it finds its revocation, writes removed.json and from then on refuses to sync, pull, push or sign (the error is DeviceRemoved; the interface shows who removed it and when). It never receives the new key.
  • Exporting the key. Pairing and varsto recovery kit use the newest epoch's key, which opens the older epochs through the epoch records. Print a new recovery kit after a revocation: the old kit holds the old key, which join refuses.

Remote wipe. With --wipe the revocation carries a wipe order. The removed device carries it out only when the revocation verifies against a trusted device of the vault that it knows; a forged order (signed by a key the registry does not know, a known device id with another key's signature, or garbage) is ignored, so neither the storage nor anyone holding only the vault key can trigger it. The device then:

  1. empties every attached folder root: every file, synced or not, with links removed and never followed; the root directory itself stays. A root that is the file system root, the user's home directory or a directory that contains the device directory is too wide to empty blindly: there only the files the folder state names (and their placeholders) are removed;
  2. removes from its device directory the keys, keyring, storage secrets, vault keys, ledger, sync state, trash, configuration, MCP grants, disk pool indexes, peer certificate and service log;
  3. writes wiped.json (who removed it and when, nothing secret); the service drops the vault and the interface shows the setup screen with that notice.

Nothing outside the folder roots and the device directory is touched, and the storages are left alone. Changes that exist only on the wiped device are lost: that is the point of wiping a device that is gone. Revoke without --wipe when it might come back.

What a removed device can still read. Everything it could read before: a key cannot be recalled, and every key it held still opens what it protected. In detail: all content, names and ledger entries written before the revocation; whatever other devices write before they adopt the new key (until each one's next sync); everything in frozen folders, including what is added to them later; rendezvous records until they are re-sealed; replica batches (the replica key stays derived from epoch 0, so replicas keep working, but the removed device can still read replica claims, which name objects, and could publish claims as a fake replica); and it can still recognise the vault's pool disks and LAN beacons (pool ids and the beacon tag also stay derived from epoch 0). It cannot read new ledger batches, device, folder, policy and rendezvous records, manifests, thumbnails or chunks of re-keyed folders, and it cannot authenticate to peers that have adopted the new key.

Limits.

  • Storage credentials are not rotated: the removed device can still list, read (ciphertext only) and delete objects on the storages until the credentials are changed at the provider. Varsto cannot do that for you.
  • There is no vault identity key (K5) and no second factor yet: any full device that is not revoked can revoke any other. A thief with an unlocked device who acts first can remove your other devices; revocations are applied in the order of their own timestamps, which the issuer chooses.
  • The wipe order travels through the storages only, not through peers, and runs only when the device runs Varsto again and reaches a storage. It does not reach copies made earlier, removable disks, backups or files opened in other apps.
  • Two devices revoking at the same instant through different first storages can each create an epoch with the same number; nothing reconciles that yet.
  • A device enrolled while the revocation was being issued, which the revoking device had not seen, is not in the new member list and has to be paired again.
  • Every remaining device must run 0.0.1-alpha.8 or later: older versions neither publish a KEM key nor understand epoch keys.
  • Existing data is not re-encrypted. Re-encrypting a folder under new keys (so that even a copy of the old keys and the storage reveals nothing) is a separate, expensive action that does not exist yet.

Differences from the key hierarchy draft: no K5 (membership statements are signed by device keys); the rotated key is the vault key K3 rather than per-folder K9 epochs wrapped to K8 keys; chunk keys are convergent (section 3), so they are not re-wrapped and only new chunks use the new keys; storage credentials are not rotated.

Tests: crates/varsto-core/tests/revocation.rs (three devices: A removes C, A and B keep syncing, C's later batches and manifest are ignored, nothing written afterwards opens with C's keys, a new device joins with the current key and is refused with the old one; a wipe order empties C's folder and device directory; forged wipe orders are ignored), unit tests in engine/membership.rs (revocation and grant signatures) and p2p.rs (a revoked device's token is refused).

22. Ledger upkeep: checkpoints and pruning (0.0.1-alpha.8)

Every sync, fetch, verification run or disk check used to add a batch of about 8 KB (mostly the hybrid signature) that every device kept forever, on every metadata storage and locally, and every pull listed the whole ledger/ prefix while every read of the view replayed every batch. Section 6 now describes the incremental mailbox, the cached view and one batch per action; this section adds checkpoints, acknowledgements and pruning, which bound what is stored.

Measured with 20 000 synthetic batches of three devices on one local directory (cargo test --release -p varsto-core ledger_scale_benchmark -- --ignored --nocapture): a pull with nothing new went from 20 004 listed keys and 668 ms to 3 empty listings and 65 ms; the view from 434 ms (replay) to 37 ms; after checkpoints and pruning the storage holds 29 objects and 5.2 MB instead of 20 003 objects and 171 MB, and a device that joins reads three checkpoints and a few batches (0.5 s) instead of ingesting every batch (35 to 57 s).

Acknowledgements. A batch body has a new field seen (omitted when empty): the newest batch of every other device that the writer held when it sealed the batch. The view keeps, per device, the highest value it acknowledged for each other device (acks, merged by maximum). After a sync a device that holds a checkpoint of another device newer than what it last acknowledged seals a batch, empty if nothing else is pending, so others learn it.

Checkpoints. ledger/<device>/checkpoint-<seq 16 digits>.json, written by the device itself:

Field Content
format_version, sig_alg, key_id as in a batch; key_id is ledger or ledger@<epoch>, the current epoch's ledger key
device, seq the checkpoint covers batches 1..=seq of this device
head_hash hash of batch seq; batch seq + 1 names it as prev
body_hex XChaCha20-Poly1305 under the ledger key, associated data device, seq, head_hash, of zstd-compressed JSON {device, seq, head_hash, created_utc, view}
hash, sig_hex BLAKE3 of the ciphertext; signature over device || seq || head_hash || hash (domain ledger-checkpoint-signature, distinct from batches)

view is the location view that the device's own batches 1..=seq produce on their own, before retirements and replica claims are folded in: chunk records with the stamps that decide object names and sizes, manifest sequence numbers, the device name, folders, retirements, acknowledgements, the number of batches and the largest Lamport time. Because applying batches is order-independent (section 6), merging this view gives exactly what replaying those batches gives.

A full device writes a checkpoint after a sync when its own batches since the last checkpoint reach at least 256 and at least the size of the last checkpoint divided by 8 KiB, so checkpoints cost at most about as much as the batches they replace (a device with 6 667 batches of synthetic claims wrote a checkpoint of 0.83 MB). It is pushed with the batches to every metadata storage, cold ones included. Members of shared folders and replicas write none.

Reading. A pull also sees the checkpoints of each device (their names sort after every batch name, so a listing after the local head always includes them). The newest one within the device's revocation cut-off is fetched when it is newer than the one held; it is kept when its signature verifies against the device record, it opens, and it agrees with the batch held at seq if any (otherwise the device is marked forked). Batches it covers are then neither downloaded nor kept. The view of each device's contribution starts from its newest checkpoint whose key this device holds and that lies within the cut-off, and applies only the batches after it; batch seq + 1 is chained to head_hash. A new checkpoint as starting point rebuilds the cached view (from the checkpoints, which is fast).

Pruning. The readers are the full devices of the vault that are trusted and not revoked (section 21). Members of shared folders and replicas do not read the vault ledger and are not asked. When every reader other than the device itself has acknowledged at least seq of its newest checkpoint:

  • the device deletes, on every hot metadata storage that holds that checkpoint, its own batches up to seq - 8 and its older checkpoints, and drops its local copies of them; the last 8 batches before the checkpoint and everything after it stay;
  • every device drops its local copies of another device's batches up to that device's newest checkpoint once the same condition holds for it.

Cold storages are never pruned (early deletion is billed and saves nothing there). Progress per storage is kept in ledger/pushed.json. A storage that lost the batches, or a new storage, receives the checkpoint and the batches after the pruned range.

Fork detection. A copy of a device restored from a backup older than the checkpoint still finds batches or a checkpoint of its own identity after its own head, fences itself and refuses to sign or push (test checkpoints_prune_after_everyone_has_seen_them). Observers detect a checkpoint that contradicts a batch they hold, two checkpoints at the same sequence number with different head_hash, and a batch after the checkpoint that does not chain to it.

Compatibility. Versions before this one read only the numeric names under ledger/<device>/ and skip the checkpoint names, and they ignore the seen field (unknown fields are ignored). They never acknowledge, so nothing is pruned while a full device of an older version is in the vault: they keep reading every batch. New fields in local files (base and dropped in the heads file, pruned in pushed.json) have defaults.

Limits.

  • A full device that never syncs again (lost, reset without being removed) blocks pruning of every other device's batches until it is revoked.
  • Members of shared folders and replicas keep all their batches. Folder manifests are listed incrementally and pruned since section 25.
  • A checkpoint under a key epoch this device has not adopted yet is downloaded again on every pull until it has.
  • Each pruned batch is one delete request; the first checkpoint of a device with a long history decrypts all its batches once.
  • varsto ledger and /api/ledger list only the batches still held locally.
  • If a revocation's cut-off lies below the newest checkpoint a device holds of the revoked device, that checkpoint is not used and the batches up to the cut-off are used where they are still held. Local copies are dropped only after every reader acknowledged the checkpoint, so a cut-off set by a reader cannot lie below it; a cut-off taken by a device that had not seen the checkpoint (it was offline) leaves out the revoked device's claims it no longer holds: copies count less, never more.
  • What a checkpoint means for signatures and audits is in ledger-signing-notes.md, section 9.

Tests: ledger::tests::checkpoint_stands_in_for_its_batches, ledger::tests::cached_view_equals_full_replay (random arrival orders, unknown keys, cut-offs, checkpoints, save round trips), engine::ledger_upkeep::tests (mailbox_reads_only_what_is_new, push_refills_a_storage_that_lost_batches, restored_copy_is_fenced_by_incremental_listing, cached_view_survives_restarts, sync_seals_one_batch, fetch_is_sealed_with_the_next_batch, one_batch_seals_on_failure, checkpoints_prune_after_everyone_has_seen_them, and the ignored ledger_scale_benchmark).

23. Data placement: per-folder placement, moving data and disk groups (0.0.1-alpha.9)

Until this release every block went to every storage of the device that wrote it. A folder can now name where it is written, blocks can be moved from one storage to another (so that idle data costs less), and a disk pool can keep copies on disks in different places.

Dropping a copy: chunk_dropped. A new ledger event {type: "chunk_dropped", folder, chunk, object, storage} records that the device removed the object from the storage on purpose. The view keeps, per chunk record, the newest drop per storage (dropped: storage -> Lamport time, merged by maximum) and, per copy, the newest claim alone (claimed_lamport, merged by maximum; lamport stays the newest claim or verification). Once every batch is applied, a copy on a storage with a drop counts only if its newest claim is later than the drop (Lamport time; on a tie the drop wins). A verification made after a drop does not bring the copy back: it may have read the object just before it was deleted. A claim made later (a new upload, or a move back) counts again. A copy whose claim predates this release has no claimed_lamport and is compared by lamport. Like retirements, drops are applied after all batches, so the result does not depend on arrival order; the cached view (ledger/view.enc, format 3, rebuilt once from the batches after an update) and checkpoints carry dropped and claimed_lamport, so a reader that starts from a checkpoint sees the drops its batches held. The finished view keeps dropped, so writers know that a block was moved away from a storage (ChunkRecord::dropped_from).

Per-folder placement. A folder record has an optional placement with storages (names), storage_ids (name -> storage identity, vault/storage-id.json, section 20) and places; none, or an empty one, means every storage. It is published like a policy, append-only, at vault/placement/<folder>/<device>/<updated_utc 20 digits>.enc (XChaCha20-Poly1305 under the folder-record key, associated data placement-record, vault id and folder id, JSON {folder_id, device, updated_utc, placement}), and every device adopts the newest one it can read. A device writes a folder's blocks to the storages the placement names: by name, by place, or by identity when it names a storage differently than the device that set the placement (two devices calling one bucket "box" and "primary" write the same way). When none of the named storages or places exists on a device it writes to every storage rather than nowhere, and placement says so. Transferrers keep their own rules. Records, ledger batches, manifests and thumbnails still go to every storage. Placement decides only where pushes and disk fills write: blocks already stored stay until they are moved, policies still count every copy the ledger records, and a block that was moved away from a storage is not written back to it by a later push while another copy exists (a block whose only copies are gone is written again anywhere the placement allows).

Setting a placement checks that every named storage is configured here (and is not a transferrer) and that every named place has a storage here. varsto folder placement <folder> shows it and the storages it means on this device; --storage X (repeatable), --place P (repeatable) set it; --every clears it. API: GET /api/placement (every folder, with the storages), POST /api/placement {folder, storages, places, every}. The interface shows it in a "Written to" column of the policy table, with a Change button (and a Placement button on phones).

Moving data. Engine::move_data (varsto move <folder> --from A --to B [--idle-days N] [--dry-run] [--confirm-cold-read] [--yes], POST /api/data/move {folder, from, to, idle_days, dry_run, confirm_cold_read}) moves a folder's blocks, or with idle_days only those of files not used for that many days and that no other current file uses, from storage A to storage B. The device first pulls the ledger, then plans from it:

  1. a block is a candidate when the ledger records a claim on A; a block already recorded as dropped from A counts as moved;
  2. it stays on A when the copies left afterwards (every storage with a claim except A and transferrers, plus B) would break the folder's copy rules (min_copies, min_per_place), and when the only readable copy is on a cold A and the cold read is not confirmed (copies on this device and on other hot storages need no cold read);
  3. the plan reports blocks and bytes, the fewest copies any block keeps and where, the monthly cost of the bytes leaving A and arriving on B and the difference (from the price model of section 20, per currency, never converted; storages without a price are listed), reading the blocks this device lacks from A once, what A still bills for its minimum storage duration, and for a cold B what reading back needs and costs.

With --dry-run that is all. Otherwise blocks are moved in groups of 64: each block is written to B unless B already has it (re-encrypted from the files on this device, each file read once and its access time put back so idle files stay idle; else from the block cache, another hot storage, or A itself when A may be read), then checked on B (read back and hash-checked; on a cold B without a confirmed cold read only its presence is checked), then chunk_stored and (when read back) chunk_verified for B and chunk_dropped for A are recorded. The group's events are sealed into a batch and pushed before anything is deleted from A; then the objects are deleted from A, except an object another ledger record still claims there. A move that is interrupted is carried on by running it again: blocks already on B are not copied again, and blocks recorded as dropped that are still on A (the batch was pushed but the delete did not happen) are deleted (leftovers_removed). A move never deletes a copy whose replacement was not written and checked, never one the copy rules need, and never the last one. Moving a whole folder (no idle_days) also changes its placement, once no block was kept, to the storages it was written to with A replaced by B, so new blocks go to B too; moving idle files leaves the placement alone, and a file that changes later is written to the hot storages again. The CLI asks for confirmation with the plan's summary unless --yes and exits with code 1 when blocks were kept.

Cold storage. A cold storage is still never read without an explicit confirmation. Blocks moved to cold storage are not read by pulls; a device that does not hold such a file sees it as unavailable until the blocks are moved back (varsto move <folder> --from <cold> --to <hot> --confirm-cold-read; without the confirmation they come from this device's files when it has them). Writing to cold storage needs no read; checking a copy there by content does.

The suggestion. Besides free-idle:<folder>, the placement advice (section 20) now offers cold-idle:<folder> when a move lowers the bill: for each hot storage with a storage price and each cold storage with a lower one in the same currency, it plans the idle-files move (idle_days of the advice) and suggests the one that saves most per month, if any saves anything. The suggestion carries kind (free-idle or cold-idle), move_from, move_to, monthly_saving, one_time_cost, the plan's summary (copies afterwards, the change in the bill, cold-read notes) and the blocks that would stay. varsto advice apply cold-idle:<folder> (asks unless --yes), POST /api/advice/apply {id} and the "Move to X" button (with a confirmation that states the same) carry it out; the report has moved with the move's report.

Disk groups. A disk of a pool has a place (place in the disk registry and in the disk's own index.json; empty means the pool's place), set with varsto disk add ... --place P, varsto disk place <label> <place> or POST /api/disk/place {label, place}. A pool has a rule copies (default 1, omitted when 1): keep that many copies of every object on disks in different places (varsto storage add-pool <name> --copies N, varsto disk group <pool> --copies N, POST /api/disk/group {pool, copies}; varsto disk group and GET /api/disk/groups show every pool's rule, its places with their disks, the objects short of the rule and why the rule cannot be met as configured). The pool index keeps, per object, the first disk and extra disks holding further copies (older versions ignore extra and know the first copy only). A put writes the first copy as before and further copies to attached disks of other places that have room; disk add and disk check fill a disk with every object the pool lacks or holds in fewer places than copies and not yet in the disk's place (copies come from the pool's other attached disks, other hot storages or local files); a read uses any attached disk with a copy; a delete removes every copy (queued for disks that are away); a check that finds a bad or missing copy forgets that copy only. The ledger still records one claim per pool. Policies count one copy per disk holding the object, at the disk's place, so "one copy at home and one offsite" can be met by one pool; a folder with a policy whose blocks on a pool with copies > 1 are held in fewer places gets a violation ("N chunks have fewer than K copies on disks of pool P in different places"), and disk check reports group_copies and group_short. The interface shows each disk's place, a Place button per disk, the rule of each pool with a Disk group button, a Place field when adding a disk and a copies field when adding a pool. With placement, a folder can be kept on disks only (varsto folder placement <folder> --storage <pool>), as many times as the rule says.

Compatibility. Devices of 0.0.1-alpha.8 and earlier cannot decode a batch that holds chunk_dropped: their ingest fails and so does their pull, from the first move on. Update every device before moving data. This version reads unknown event types as unknown and ignores them, so later event types will not break it. Older versions also ignore dropped and claimed_lamport in checkpoints, placement records (they write every folder to every storage) and the extra disks of a pool index. Storage format version stays 1: objects, records and batch envelopes are unchanged.

Limits.

  • Storage names in the ledger are the writer's names; a drop recorded under one device's name for a storage applies to the claims made under that name (as retirements do).
  • A device that wrote a block to A without having seen a drop, with a later Lamport time than the drop, may record a claim on A for an object the move deleted just after; the copy then counts until verification reports it missing.
  • A file whose blocks are only on cold storage cannot be fetched or opened until they are moved back; pulls do not ask for a cold read.
  • Varsto's own reads of a file at scan and upload still update its access time, so a file reads as used right after it changed or was uploaded to a new storage.
  • The ledger records one claim per pool; which disks hold copies is in the pool index of each device (adopted from the disks' own indexes).
  • Strongroom conversion re-encrypts into every storage; the new folder keeps the placement for later writes.
  • Moves are not scheduled by the service; the suggestion and varsto move are the ways to run one.

Tests: crates/varsto-core/tests/placement.rs (a_drop_removes_earlier_claims_and_later_claims_count_again with the cached view and a checkpoint, unknown_event_types_are_read_and_ignored, placement_writes_only_to_the_chosen_storages_on_every_device with two devices that name storages differently, moving_keeps_the_policy_and_lowers_the_bill with the cold-storage suggestion and a second device, moving_a_whole_folder_changes_its_placement_and_never_drops_the_last_copy, an_interrupted_move_is_carried_on_by_running_it_again, disk_groups_keep_copies_in_different_places), and ledger::tests::cached_view_equals_full_replay, whose random events now include drops.

24. Shared folders: confirming the requester, removing members, share epochs (0.0.1-alpha.9)

Code: crates/varsto-core/src/share.rs (objects, fingerprint) and engine/share_ops.rs.

Fingerprint. A request code is vsr1.<X25519 key || ML-KEM-768 key, hex>[.<name>]; varsto share request --name <name> (or the device name in the interface) binds a name. Both sides derive six BIP-39 English words from it: derive_key("varsto share request-fingerprint v1", len-prefixed(public key, trimmed name)), the top 66 bits, 11 per word. The derivation does not depend on the format version, so two users on different versions see the same words. The recipient sees them next to its code; the owner sees them for the code it received (share create --to <code> asks "Do the six words match exactly?", --fingerprint "<words>" or --yes answer it; the interface shows the words and asks before sealing; POST /api/share/fingerprint {code}). The two compare them over another channel (a call, in person). A changed key or name gives other words. 66 bits make a second preimage (a code of the attacker's with the victim's words) cost about 2^66 key generations.

Version-2 tokens. vst2.<vault>.<folder>.<kem-alg>.<kem-ct>.<sealed>.<name> has the fields of vst1, but the sealed part is a JSON payload {key_hex, epoch_keys, fingerprint, owners} under associated data (share-token-v2, vault, folder, name, kem ciphertext): the folder key, the folder keys of later epochs (a folder re-keyed with the vault key, share epochs), the fingerprint the owner confirmed and the owner's device ids. The recipient's device refuses a token whose confirmed fingerprint is not its own request's (the code was changed on the way and the owner did not notice). A token without these extras is still sealed as vst1, which older versions read. A plain token (<vault>.<folder>.<key>.<name>) cannot carry later keys and is refused once a folder has them.

Tokens and members on record. Every token the owner issues is recorded at vault/share-invites/<folder>/<invite>.enc under the folder-record key (owner devices only): invite id (hex(BLAKE3(request key))[..32], or plain-<random>), the request key, the bound name, the fingerprint, issuer and time. Owner devices that see an invitation for a folder mark it shared. A member keeps its request key as its key-exchange key and publishes it at vault/share-kem/<folder>/<device>.json (the KEM record of section 21, signed by the member's device key); the owner ties a member to the token it accepted by that key. varsto share members <folder> and GET /api/share/members?folder= list the owner's devices, members (active or removed, with the fingerprint the owner confirmed) and tokens not accepted yet (pending or revoked). The Shared page lists the same under each shared folder.

Share epochs. varsto share revoke <folder> <member|invite id|fingerprint|all> --yes, POST /api/share/revoke {folder, member, confirm: true} and Remove…/Revoke… on the Shared page start a share epoch:

Object Content Protection
vault/share-epochs/<folder>/<epoch, 10 digits>.enc folder, epoch, issuer, time, older (every earlier folder key except epoch 0: earlier share epochs and vault-epoch folder keys), participants (owner devices and members that stay), owners, removed (device, name, ledger cut-off), revoked_invites, kept_invites XChaCha20-Poly1305 under derive(K_e, "share-epoch-record", folder), associated data vault, folder, epoch: only holders of the new key read it
vault/share-keys/<folder>/<epoch>.enc the new folder key K_e the vault's folder-record key (owner devices of every later vault epoch)
vault/share-grants/<folder>/<epoch>/<recipient>/<issuer>.json K_e for one member (<device>) or one open token (inv-<invite id>) hybrid KEM to the member's key or the token's request key; AEAD with vault, folder, epoch, recipient and issuer as associated data; signed by the issuing owner device
vault/share-members/<folder>/<epoch>/<device>.enc a participant's device record derive(K_e, "share-registry", folder)

Share epoch numbers start at 2^20 (SHARE_EPOCH_BASE) so they never meet vault epochs in a chunk reference. K_e is random. A folder writes under its newest share epoch: chunks (chunk-key, chunk-nonce from K_e, convergent within the epoch), manifests and thumbnails (folder-metadata), access records, member ledger batches (derive(K_e, "share-ledger", folder), key id share:<folder>@<epoch>), member records. The keyed-hash key stays that of epoch 0, so chunk ids and content hashes, deduplication and merging do not change; a chunk the folder already references keeps its object and epoch.

The owner device that revokes: pulls the ledger (the cut-off of a removed member is the newest batch of it held there); writes the epoch record at the first epoch number whose record and key paths are free on the first storage (another owner device may have taken one at the same moment, and anyone who can write to the storage can occupy a name), then the key record; adopts the epoch; seals the key to every participant member that has published a key-exchange key and to every token still open; republishes its records under the new key. Every owner device, on each sync, adopts share keys it lacks, applies their epoch records, and seals its own grant to participants that still lack one (a member running an older version gets it once it publishes a key). A member adopts an epoch whose grant to it (or to its token) opens and is signed by one of the owner's devices: the token names them, and each adopted epoch record updates the list. A member that joined before tokens named the owner's devices accepts a grant from any participant whose record it holds.

Effects, on owner devices and on the members that stay:

  • Ledger. The removed member is entered as revoked with its cut-off (devices.json), exactly like a revoked device (section 21): its batches after the cut-off are not ingested or counted, and its manifests count only up to the last one its accepted batches announced. Its new batches are sealed under the old key anyway, which the others still hold.
  • Member records. Once a folder has a share epoch, a member record that opens only under an older key is accepted only for a device the newest epoch kept, an owner device, a removed member (for its batches before the cut-off) or a device already known; its device id must be the hash of its key. A removed member therefore cannot come back as a new device with the key it kept; manifests of a shared folder with a share epoch are read only from known devices.
  • The removed member keeps everything it could read: a key cannot be recalled. It sees that a newer epoch exists and that no grant reaches it (access_lost, shown by share members and the Shared page); its sync no longer fails on manifests it cannot open, and it receives nothing written under the new key. It is not told who removed it.
  • A revoked token opens as before but brings nothing written after the revocation; a device that accepts it is not a participant, so it is ignored as above. Tokens still open get the new key through the grant addressed to their request key, so revoking one token does not break another.
  • Sharing again works: the token carries every later key, and the new member's record is written under the newest key.
  • Sharing a folder re-keyed with the vault key starts a share epoch first (nobody removed), so the token's keys and everything written from then on stay away from the device the vault revocation removed.

Why not random per-chunk keys. The key hierarchy proposed random chunk keys (K14) wrapped under the folder key, so that a removal re-wraps keys instead of re-encrypting data. They were not adopted: chunk objects must stay byte-identical across devices for offline deduplication and for peers that re-encrypt a piece from plaintext on request (section 15), and storing a key per chunk in every manifest would make manifests much larger. Within one share epoch keys stay convergent; across epochs a random folder key gives what matters after a removal: nothing written afterwards opens with what the removed member holds, and it cannot even tell whether new chunks match content it knows, since chunk keys and nonces now come from a key it lacks. What a removal does not do (and random chunk keys would not have done without re-encryption either): existing data stays under the old keys, so the removed member keeps reading what it had; it still knows the epoch-0 keyed-hash key and can recognise chunk ids of content it knows in a manifest or ledger batch it can open, which are all old ones.

Limits. The storage stays shared: a removed member can still list, read (ciphertext) and delete objects, or occupy object names, until its access to the storage is taken away; storage credentials are the user's. Revocations by two owner devices at the same moment produce two epochs, both applied. A member that did not publish a key-exchange key (an older version) gets the new key only after it does. A device of the owner removed from the vault (section 21) keeps the share keys it already held, as before; a share epoch started afterwards is out of its reach. The removed member is not notified explicitly. Tests: crates/varsto-core/tests/sharing.rs (fingerprint on both sides, a swapped name; three users and two owner devices: the removed member reads nothing new and its later changes and a new device of it are ignored, the remaining member and the owner's other device read on, sharing again; revoking a token not accepted while keeping another; removing everyone; sharing a re-keyed folder; access records merged across devices), share::tests, and tests/strongroom.rs (rekeying_a_strongroom_re_encrypts_it_under_a_new_key_for_every_enrolled_key).

25. Manifest listing and pruning (0.0.1-alpha.9)

Every push that changes a folder publishes a full manifest (section 7), and a pull used to list manifests/<folder>/ in full on every hot metadata storage, so a pull with nothing new cost one listing whose length grew with every manifest any device had ever published, and nothing was ever deleted.

Listing. Every manifest is announced in its writer's ledger batch (manifest_published, sealed in the same action as the upload), and a pull reads the ledger first. A pull of a folder therefore lists only the devices whose newest announced manifest of the folder (the manifests map of the location view, which for a revoked device counts only batches within its cut-off) is newer than the one this device has applied (last_seen in the folder state), and for each only the keys after that one: list_after("manifests/<folder>/<device>/", "manifests/<folder>/<device>/<last seen 16 digits>.enc"). With nothing new, nothing is listed and nothing is read. The newest key found across storages is fetched, capped for a revoked device as before, and applied with the usual merge rules; conflicts and forks are handled exactly as with a full listing because the same manifest is chosen.

A full listing of the folder still runs on the first pull of a folder on a device, once every six hours per folder (state/manifests.json keeps the time), and always on members of shared folders, which do not read the vault ledger. It finds manifests whose announcement this device cannot read yet (a key epoch not adopted, an older device). A folder converted into a Strongroom has a new folder id, so its first pull is a full listing of the new prefix.

Pruning. A reader only ever applies the newest manifest of a device, or for a revoked device the newest one its accepted batches announced. After a sync a full device notes, per folder, its newest published manifest P with its own newest batch H (the batch that announced P is H or earlier; nothing is pending at that point). Let m be the largest noted P whose H every reader has acknowledged in the seen field of its batches (the readers and acknowledgements of section 22: trusted full devices that are not revoked). The device then deletes its own manifests of the folder below m on every hot metadata storage that holds manifest m; cold storages are never pruned. Progress per storage is kept in state/manifests.json, so each prune lists only the device's own keys from the last pruned one.

Why it is safe: every reader holds the batch announcing m, so it knows of m or newer and its next pull lists from its own last applied manifest, finding m or newer. A revocation cut-off taken by any reader lies at or above the batches it acknowledged, so the newest manifest within the cut-off is m or newer, and everything from m on is kept. A pull that listed a key just before its writer pruned it skips the missing object and finds the newer one on the next pull. Members of shared folders read only the newest manifest and are not waited for; they and replicas prune nothing of their own.

Measured with cargo test -p varsto-core --test manifests manifest_listing_benchmark -- --ignored --nocapture (three devices on one local directory, one of them publishing 300 versions of a folder; debug build): a sync with nothing new listed 310 manifest keys in one listing before (a full listing on every pull) and none after; a sync with one new manifest of another device lists one key and reads one manifest. With pruning, the 302 manifests of the busy device on the storage (208 KB of manifests in all) went down to its newest 2 (12.6 KB in all), so even the six-hourly full listing returns 6 keys instead of 310. Acknowledgements travel in the readers' next batches, so in a quiet vault pruning waits until the other devices write something.

Limits. A full device that never syncs again blocks pruning, as for the ledger, until it is revoked. Devices of older versions never acknowledge, so nothing is pruned while one is in the vault. A device whose announcement this device cannot read is found only by the six-hourly full listing. Manifests are still full views of a folder; incremental manifests are not built.

Tests: crates/varsto-core/tests/manifests.rs (pull_lists_only_new_manifests: nothing listed or read when nothing is new, one key and one read for one new manifest; pruning_waits_for_every_reader: a quiet device keeps the manifests it has not acknowledged, they go once it has, everyone converges and a device that joins later reads the few left), revocation.rs and strongroom.rs (cut-offs and conversions with incremental listing), unit test engine::manifest_upkeep::tests.

26. Automatic repair (0.0.1-alpha.9)

Verification (section 20) and fsck found damage and only reported it. They now also put each damaged copy in a repair queue, state/repair.json: one entry per (object, storage of this device) with the folder, chunk, kind (missing or corrupt), who found it and when. fsck queues objects that the ledger claims on a hot storage of this device that the listing lacks (claims made by this device, or blocks of current files, because storage names are local and another device's "box" may be another place), blocks of current files that no hot storage has at all (for every hot storage that takes every block), and with --verify objects whose hash does not match. Verification queues the missing and corrupt copies it finds, under the local name of the storage it read.

A repair run sorts the queue by the number of other copies the ledger knows (fewest first), then by age, and for each copy within the budget:

  1. It drops the entry when the storage is no longer configured here (a storage that is gone for good: removing a storage is its own action), when the storage is cold or a transferrer (repair never writes those; transferrers only take what another device lacks), when the copy was dropped from the storage on purpose (chunk_dropped, section 23, and no claim since) or the folder's placement does not take the storage (a data move is never undone), or when the folder is attached here and no current file uses the block any more.
  2. It reads the copy again. An intact copy (another device repaired it, a disk came back) is recorded as chunk_verified. A storage that cannot be reached, or a pool disk that is away, is tried again after an hour.
  3. It looks for the same ciphertext elsewhere and checks it against the object name: the encrypted block cache; the other hot storages of this device; this device's plain copy of a file that holds the block (only when the file on disk is the synced version), re-encrypted, which gives the very object because chunk encryption is deterministic (section 3); a peer (section 15). A cold storage is read only when the user confirmed it (varsto repair --from-cold, or the button in the interface, which warns about retrieval fees and delays).
  4. It deletes a corrupt copy, writes the object with put-if-absent, reads it back, checks the hash, and records chunk_stored for the storage in the ledger (one batch per run). Repair is the one exception to "objects are never overwritten", and only for a copy whose bytes do not match its own content-addressed name.

A copy for which nothing is found stays in the queue with the reason ("a copy is on cold storage deep; run varsto repair --from-cold", "the other copy is on a disk of pool shelf that is not attached", "no other copy can be read, and the Strongroom is locked", or "no intact copy anywhere this device can read") and is tried again a day later, when a peer may be online or a disk attached.

Where it runs. The background service runs the queue after every sync and after automatic verification, when something is due, with a budget of 500 blocks or 256 MiB per run; it never reads cold storage. Every copy that could not be repaired raises one desktop notification ("2 block copies could not be repaired: "); the same copy is not notified again while it stays unrepaired. varsto repair [--dry-run] [--from-cold] [--no-scan] [--max-mib N] [--max-blocks N] compares the ledger with the hot storages first (an fsck without downloads), then repairs everything queued, without a budget unless one is given; --dry-run says what would be rewritten from where and writes nothing; the exit code is 1 when a copy could not be repaired. varsto fsck --repair repairs what that fsck found. API: GET /api/repair (queue, last run, open losses), POST /api/repair {dry_run, from_cold, scan}, POST /api/fsck {verify, repair} (the report then carries repair); GET /api/service carries repair and a one-line repair_text ("automatic repair: last run …: 3 copies repaired, 1 could not be repaired: why"), which varsto service status prints. The interface shows it on the Policies page (Automatic repair: status, open losses, Check and repair now, Preview, Repair from cold storage when a loss needs it), verification and repair run on their own in the service; the interface has no manual check.

Limits. Only storages of this device are repaired; a copy on another device's storage that this device cannot reach is repaired by that device. Blocks of folders that are not attached here are repaired only from ciphertext (another storage, the cache or a peer), and without a current-file check, so a block of an old version may be rewritten. A peer is asked only when the service (or a command-line run with peer-to-peer enabled) knows peers. Disk pools are repaired through their attached disks; filling a replacement disk stays varsto disk add.

Tests: crates/varsto-core/tests/repair.rs (from another storage with a dry run first, a corrupt and a missing copy from the local file through fsck --verify and the service's run, damage found by verification on another device, from a peer over TCP, cold storage read only with from_cold and counted reads, an unrepairable loss reported once, transferrers never written, a block moved off a storage not written back).

27. Organizations: administrators, approvals, removing a person (0.0.1-alpha.10)

Code: crates/varsto-core/src/org.rs (objects, request codes, approval tokens) and engine/org_ops.rs. Plan 6.44, phase A; it realises the vault identity key K5 of key-hierarchy.md as an organization root key plus administrator device signatures.

Why. A vault has no people in it: only devices, and every full device is equal, so any device could remove any other (section 21) and anyone holding the vault key belonged. A company needs someone who decides which devices belong, can remove a person with every device of theirs, and can show afterwards who did what. An organization adds that as a layer on top of the vault. A vault without one is unchanged: no new objects, no new checks, nothing in the interface beyond the Use with a team card under Settings.

Keys. The organization has a root key: a random 256-bit secret shown once as 24 BIP-39 words (varsto org create, Set up an organization… under Settings) and never stored on any device. The root signing key is the hybrid Ed25519 + ML-DSA-65 pair whose seeds are derive(root, "org-root-ed25519") and derive(root, "org-root-ml-dsa"); the organization id is the first 16 bytes of the hash of its public key. The root key signs the manifest only (who the administrators are); everything else is signed by administrator device keys, the ordinary device signing keys of section 3. Bodies are encrypted under derive(K_epoch, "org-record") with the vault key of the epoch named in the envelope, so the storage reads nothing and a device that joined later opens older objects through the epoch chain.

Objects (under org/ on every metadata storage, written with put-if-absent):

Object Content Protection
org/manifest/<seq, 8 digits>-<attempt, 3 digits>.json org id, name, seq, hash of the previous manifest as stored, time, root public key, administrators (device id and name) body encrypted; signed by the root key over vault, org id, seq, key epoch and the ciphertext hash
org/roster/<seq>-<attempt>.json org id, seq, hash of the previous roster, the manifest that made the issuer an administrator, issuer, time, members (device, user, name, when and by whom added), removed (device, user, name, when, by whom, wipe), policy body encrypted; signed by the issuing administrator device
org/log/<device>/<seq>-<attempt>.json one entry of that administrator device's log: seq, hash of its previous entry, time, event body encrypted; signed by the issuing administrator device

A manifest, a roster and each device's log are hash chains with contiguous sequence numbers. The roster is a snapshot, not a delta: the newest valid one is the truth. Rosters carry the policy: whether members may share folders with people outside the vault, add or remove storages, set policies and placement, or add devices and print the recovery kit. Every flag is off by default; administrators may do everything.

What a device accepts (sync_org, run on every pull before revocations are read and again after a new key epoch is adopted): the next manifest in sequence whose previous-hash matches, signed by the root key the device already knows (the first manifest it ever sees pins that key; a vault whose storages hold a manifest the device cannot read refuses to create another); the next roster in sequence whose issuer is an administrator in the manifest it names and is not revoked; the next log entry of each device that is or was an administrator. Anything else is ignored. The result is kept in org-state.json.

Who belongs. Once a roster exists, a full device is trusted (section 21: its records, ledger batches, revocations and grants count) only if the roster lists it. A device that joins with the vault key alone therefore reads everything the key opens, but the others ignore what it writes, and the organization page lists it under "not in the organization" until an administrator adds it (varsto org add-device <device> --user <person>). Devices that existed when the organization was created are listed under its founder.

Approval. A new device runs varsto org request [--name <device>] (the name defaults to the computer's name) (or Join your organization on the setup screen): it creates its key-exchange key and its device signing key, keeps both in org-request.json, and prints vor1.<KEM public key, hex>.<device id>[.<name>] with a six-word fingerprint over the key, the device id and the name (the derivation of section 24). An administrator pastes the code, compares the words with the person, names the person and approves: the device goes into the roster (so its id is known before it writes anything), the log gets device_approved, and the device receives vot1.<vault id>.<KEM ciphertext>.<sealed>: the pairing bundle of section 9 (vault key of the current epoch, storages with their secrets) plus org id, name, user, approver and the confirmed fingerprint, sealed through the hybrid KEM to its request key. varsto org join --token (or the setup card) refuses a token whose confirmed fingerprint is not its own, joins through the first reachable storage like pairing, and signs with the key it made at request time.

Removing. Only administrators can revoke devices, and only epochs and revocations issued by administrators are adopted; a member device that tries gets an error, and a forged epoch record from a member is ignored. varsto org remove-user <person> --yes [--wipe] (Remove … on the Organization page) revokes every current device of the person in one key epoch (revoke_many: one epoch record naming them all, one signed revocation each, the new key sealed to the devices that stay), moves them to the roster's removed list and logs user_removed. Removing a single device with varsto device revoke on an administrator device keeps the roster and the log in step. The founder cannot remove the person who owns the device it runs on; another administrator does that. What a removed device can still read is exactly what section 21 says: nothing new, everything old; storage credentials are not rotated.

Administrators. varsto org admin add <device> --root-words "<24 words>" and admin remove publish the next manifest; the root words are typed, used and dropped. An organization keeps at least one administrator. A revoked administrator's old rosters stay valid (they were valid when written); new ones are refused because the issuer is revoked.

Log. Events: org_created, admin_added, admin_removed, device_approved, device_added, device_removed, user_removed, policy_changed, each with the names at the time. varsto org log [--json], GET /api/org/log and Export as JSON on the page give the merged, verified log, oldest first. Nobody can change an entry afterwards without the chain breaking on every other device.

Interfaces. CLI: varsto org create|status|request|approve|join|add-device|remove-user|policy|admin add|admin remove|log; varsto device list shows each device's person and role. API: POST /api/org/request, POST /api/org/join (without a vault), GET /api/org, GET /api/org/log, POST /api/org/create|fingerprint|approve|add-device|remove-user|policy|admin. Interface: Use with a team under Settings creates one; the Organization page (sidebar, and More on a phone) appears only when the vault has one and shows people and devices, approvals, the policy, administrators and the log; members see the page without the administrator actions. StatusReport.org carries org id, name, whether this device is an administrator and listed, and its user.

Limits. A member's device holds the vault key, so a modified client can read everything the key opens and can hand the key on; the policy flags are enforced by every honest device and by the roster (an unapproved device is ignored), not by the storage. Storage credentials are shared and not rotated (plan 6.44 phase B). Existing data is not re-encrypted after a removal (phase C). There is no second factor on administrator actions and no dual control. Two administrators writing the same roster or manifest sequence at the same moment collide on the first storage; the second one syncs and tries again. A device listed in the roster but removed from the vault by an administrator of an older manifest stays removed. Tests: crates/varsto-core/tests/organization.rs (create, a member's limits, approve and join, an unapproved device ignored until added, removing a person in one epoch with a wipe, a single-device removal, the policy, a forged roster ignored, appointing and dismissing administrators with the root words), crates/varsto-cli/tests/org_cli.rs (the commands end to end) and org::tests.

28. Hardening against other devices, storages and peers (0.0.1-alpha.10)

A security review of alpha.10 looked at what a device holding the vault key, a storage provider, a peer on the network or a local user could do. These changes follow from it.

Paths from other devices. Every manifest is validated when it is opened: each entry's map key must equal its path; the path must be relative, with plain components only (no .., ., empty components, backslashes, drive letters, control characters or Windows device names such as CON or LPT1; util::check_rel_path); chunk sizes must lie between 1 byte and 16 MiB; and a file's size must equal the sum of its chunks. A manifest that fails is ignored. Before a remote file, placeholder or trash copy is written, the path is joined to the folder root again with util::safe_join, which refuses to write through a symbolic link anywhere on the way. Before this, a device with the folder key could write, overwrite or move files outside the folder on every other device, and have a picture outside the folder thumbnailed and uploaded. Reading a file through the API checks the path and requires it to be in the folder's state.

Object keys. Every storage backend (directory, S3, rclone, disk pool) checks object keys with the same rule, so keys listed from someone else's storage (an untrusted replica's source) or from a disk's own index cannot leave the storage's root. A pool disk's index counts an object only when the file is there with the claimed size. rclone runs with -- before remote paths, and only its exit codes 3 and 4 mean "not found"; any other failure is an error, so an unreachable remote never looks empty.

Bounds. Decompression capacity is bounded by the 16 MiB chunk limit; in-memory reads allocate at most 16 MiB up front. A ledger batch more than 65 536 sequence numbers beyond the newest batch held (or the checkpoint) is not kept until the gap closes, so a device cannot make every reader walk to an absurd number. A Lamport clock seen elsewhere is adopted only when it is at most 2^40 ahead of the local one, and the local clock never wraps.

Signed records. In a vault with an organization, epoch records (section 21) are wrapped in an envelope signed by the issuing device (SignedEpoch); a number taken by an unsigned or unverifiable object is skipped, not waited for, and a new epoch takes the first number above everything present. The chain of epochs records the previous epoch number, so it can step over skipped numbers. A device joining with the vault key tries the epoch numbers from the top down and uses the first one its key opens. Policy and placement records are wrapped in an envelope signed by the device that wrote them (SignedRecord); the <device> in the object name must be the signer. In an organization only signed records from trusted devices are adopted (and only an administrator's unless members may set policies). A vault without an organization writes and reads the older, unsigned forms of all three: any of its devices may rotate the key and set policies anyway, so a signature would prove nothing more, and devices still on alpha.9 keep working with devices on alpha.10 (tested in the lab: sync both ways, policies set on either version, a device removed by either version, an upgrade in place).

Organizations (section 27). Object names carry an attempt number; a writer claims the lowest free attempt of the next sequence number with put-if-absent on its first storage, skipping junk, so nobody can block the chain by writing first. Readers take, among the objects of one sequence number from every storage in name order, the first that verifies; a device whose own roster lost that race steps back and adopts the winner. A manifest records the newest roster when it was written; a roster or log entry counts only if its issuer is an administrator in the manifest in force for its sequence number, so a dismissed administrator cannot keep writing by naming an older manifest, while its earlier rosters stay valid. A roster issuer must also be listed in the previous roster. Revocations are read before and after the organization on every sync. An approval token carries the root public key, which the joining device pins before its first read, so a manifest chain replaced on the storage is refused. A device that was approved but never joined can be approved again for the same person to get a new token. The request file is created owner-readable only. Removing a person pulls the newest ledger first, like removing a device.

Peers. A peer token now carries a random nonce, and the answering device accepts each nonce once inside the time window; a relay checks a token without spending it. The time window check cannot overflow. Devices the organization's roster does not list are refused by peers like revoked ones. Chunked HTTP bodies are bounds-checked, bodies without a length are capped at 64 MiB, malformed object names are refused, and a panicking connection thread no longer leaks a connection slot. Pairing confirmations are compared in constant time.

Local service. The session token is no longer printed on the service's output (which the tray and launchd write to service.log), and service.log is created owner-readable only. Download names in Content-Disposition are encoded per RFC 6266 instead of crashing the API thread on a non-ASCII name. The S3 secret given while joining is removed from the process environment as soon as the storage is open, so child processes do not inherit it. The self-update unpacks into a fresh private directory with a random name and refuses an archive with more than one binary. The MCP server's grant on every folder no longer reaches Strongroom folders; they need a grant by name.

Tests: util::tests, manifest::tests, ids::tests, p2p::tests (chunked bodies, one-time tokens), crypto::tests, and in crates/varsto-core/tests/organization.rs junk under organization and epoch names, two administrators writing at once, a dismissed administrator, and a forged policy record.

29. Background by default, block sizes by file size, peer-to-peer during uploads (0.0.1-alpha.11)

No manual buttons. The interface has no Sync, Refresh, Check storage, re-hash or Check for updates buttons any more: the service syncs within seconds of a change and on its timer, automatic verification and repair run on their own (sections 20 and 26), the page refreshes after every sync, and updates are looked for at start and every six hours in the page, the tray and the macOS menu bar and offered only when a newer version exists. The macOS app replaces the whole bundle from the signed disk image and starts again; varsto update does the same from inside the bundle. A copy that cannot install a newer version says so instead of "up to date".

The service everywhere. On Linux and macOS any command sets up the login service once a vault exists (a systemd user unit, a launch agent), for the default device directory in a terminal, until varsto uninstall, which leaves a service-off marker. Only one service runs per device directory. With a service running, folder add, attach, detach, remove and selective go through it; the service also reloads config.json and the keyring when another process changes them. The Lamport clock continues from the higher of memory and clock.json, so two processes on one device never hand out the same value. A passphrase from VARSTO_PASSPHRASE unlocks a waiting service like a typed one. Device names default to the computer's name.

Peer-to-peer on by default. P2pConfig gains user_disabled; without it peer-to-peer is on (port 17893). A device that published something new sends /p2p/changed to its peers, which sync at once. A push with peers publishes the file list and its ledger batch before the upload, refreshes what the peer-to-peer server offers and tells the peers, so devices that can reach it fetch new files while the upload to the storages runs. A peer marked unreachable is tried again at once when its record changes (a new relay, new addresses).

Block sizes. The chunker parameters follow the file size: below 32 MiB 64 KiB to 1 MiB (average 256 KiB), below 1 GiB 256 KiB to 4 MiB (average 1 MiB), larger 1 to 16 MiB (average 4 MiB). The size alone decides; no compatibility with the single set of earlier versions is kept. Memory stays bounded: at most 128 MiB of encrypted data waits for the uploaders, and about 96 MiB of blocks is in flight per downloaded file.

Default policy. A folder without a policy of its own uses 1 copy on any storage, 1 at place cloud, 1 at place home, verified by another device within 30 days. policy clear stores an empty policy, so the default does not return. A move keeps a copy only when dropping it makes the policy worse; freeing advice ignores places the vault has no storage in.

Other. varsto transfers [--watch] [--json] lists uploads and downloads running in the service (/api/progress field transfers) with peer traffic. S3 refusals are explained in words before the technical detail. Turning selective sync off downloads the placeholders on the next sync. Temporary files of atomic writes are unique per write.