Purpose: define the profile lifecycle, on-disk/at-rest forms, and the sync protocol. This is
the model proven in the field by shipping antidetect products (<uuid>.zip + <uuid>.sig, partial local materialization) and
formalized here (ADR-0004).
1. Two forms of a profile
- Runtime form: a live
user-data-dirChromium can use (cookies, localStorage, IndexedDB…). - Storage/sync form: a Profile Blob =
manifest + payload:- payload =
user-data-dir→ deterministic archive (tar/zip) → compressed → encrypted (AEAD). <uuid>.blob(ciphertext) +<uuid>.sig(signature over ciphertext+manifest).- manifest (cleartext, integrity-protected): uuid, version vector, size, core version last used, fingerprint id, createdAt, deviceId, contentHash.
- payload =
Bridges between the two: materialize (blob → dir) and pack (dir → blob). They are the only sanctioned conversions.
What a blob does NOT hold — and why the profile is still whole (A98)
A profile’s live directory is mostly not the profile. Measured on a real park (the owner’s own, 121 profiles, 2026-09-14), the heaviest profile’s 2 948 MB of live directory broke down as:
| bucket | MB | share | in the blob? |
|---|---|---|---|
| identity — cookies, Local Storage, IndexedDB, extensions, the wallet keyring, Preferences, history, bookmarks | 0.7 | 0.0% | yes, always |
Chrome’s own downloads — OptGuideOnDeviceModel (2 730 MB alone), component_crx_cache, TranslateKit, WasmTtsEngine, OnDeviceHeadSuggestModel, the component-updater payloads, Crashpad, BrowserMetrics* |
2 944.4 | 99.9% | no |
cache — Cache, Code Cache, GPUCache, the Dawn caches |
1.9 | 0.1% | yes, on purpose |
pack drops the middle bucket (EXCLUDE_TOP_LEVEL in vflin-crypto’s archive.rs). Three properties
make a name eligible and all three are required: Chrome writes it through the component updater or
the crash/metrics subsystem, it is identical for everybody, and it comes back by itself. The
exclusion matches the FIRST path segment only, so nothing inside Default/ — which is the profile — can
ever be reached by it.
The profile’s passkeys are identity, and in the blob (A133, ISSUE-311 — the owner’s F1b: «сохраняться и даже
передаваться экспортом»). With identity.passkeys: "profile" (patch 0059) the profile’s own WebAuthn authenticator
keeps its keys in <user-data-dir>/Vflin Passkeys — a top-level FILE no exclusion names, so pack seals it like any
other identity file, and the bundle carries it to profile.file.export, cloud sync and another device of the same
account. Measured (tests vflin-profiles …passkey_store_is_sealed…, daemon …passkey_store_travels_in_the_profile_export,
and gated passkeys_live_gated on the s48 core against the owner’s test site): no plaintext after the pack, byte for
byte after materialize and after the bundle moved to another store, and the same passkey signs in after a restart and
from the other store. Nothing had to change in the archive; the switch (profile.update {passkeys}) is what was missing.
The cache is kept, and A98 re-measured that decision rather than re-arguing it: a browser whose
cache is empty at the start of every session is rare, and rarity is what gets a profile caught. On the
heaviest real profile the whole cache bucket was 1.9 MB, so there was never a gigabyte here to trade.
Measured, same input, before and after:
| profile | live dir | blob before | blob after |
|---|---|---|---|
| the owner’s heaviest | 2 948 MB | 2 380 MB (the artifact on his disk) | 0.06 MB |
| a profile after twelve real pages | 66.2 MB | 28.89 MB | 17.63 MB |
Old blobs need no migration. pack runs on every profile.stop, so a profile shrinks the first time
it is opened and closed — there is no rebuild step, and a park that is never opened keeps its old bytes
until it is. A profile at rest is only its blob: pack secure-wipes the live directory, so the second
copy exists only while the profile is running.
Six weeks later the cache IS the heavy profile (A209, ISSUE-571, measured)
The table above was true on 2026-09-14 and is not any more. Measured on the owner’s own export of his heaviest
profile (2026-10-24, p2): the same archive the seal carries and the same zstd level (plain_export.rs says the
two are one), read as a stream, sizes summed per directory, nothing unpacked to disk and no site named.
Packed = each bucket through its own zstd level-3 stream; the buckets add up to 659.5 MiB against the file’s 658.
| bucket | raw MiB | packed MiB | comes back by itself? |
|---|---|---|---|
Default/Cache/Cache_Data — the HTTP cache Chrome uses |
459.7 | 256.6 | yes |
Default/Cache/old_Cache_Data_000 — a cache Chrome reset and had not finished deleting |
231.4 | 213.6 | it is garbage: Chrome deletes it |
Default/Code Cache — compiled JS / wasm |
303.3 | 85.1 | yes |
Default/Service Worker/CacheStorage — sites’ offline caches, 9 sites, the largest 171 MiB raw |
261.2 | 63.1 | yes, on the next visit |
Default/Service Worker/ScriptCache — the workers’ scripts |
51.4 | 15.4 | yes, but a registration without its script is dropped by Chrome — kept with the registrations |
Default/IndexedDB — sites’ databases, 13 sites, the largest 23.8 MiB raw |
36.0 | 22.0 | no |
Default/Sessions — open tabs to restore |
29.7 | 1.3 | no |
| GPU / Dawn caches | 11.7 | 0.1 | yes, machine-keyed |
everything else: cookies, passwords, Local / Session Storage, extensions’ state (the wallet keyring), history, favicons, preferences, Local State, vflin-fp-config.json |
20.2 | 2.3 | no |
| total | 1 404.6 | 659.5 |
What sync is obliged to carry — what does not come back by itself, plus the workers’ scripts under the “unsure means kept” rule: ≈ 41 MiB packed of 659 (6 %) on the heaviest profile. Everything else in it regrows. A typical profile is a different scale altogether: across 663 seals in the owner’s journal the median is 0.00 MB, p90 0.10, p99 0.20, the largest 238.3 MB packed (449.8 raw) — another profile of his, on 2026-10-17; the exported one never sealed in that journal (it lives in another store). Two heavy profiles, then; only the exported one is measured by directory — the other is a sealed blob, and opening it is opening his data.
Chrome’s garbage no longer rides (A209, PENDING_DELETION in archive.rs): a directory Chrome renamed to
old_<cache>_<NNN> for deletion (net/disk_cache/cache_util.cc) at the cache’s own place is left out of the pack —
on this profile that alone is 214 of 659 MiB packed.
What travels and what stays on this computer (A210, ISSUE-571)
The rule, from coordination’s brief: if it comes back by itself, it does not travel. One walk of the folder makes
two archives (vflin_crypto::pack_dir_parts_timed, the list is LOCAL_ONLY in archive.rs):
| group | where | why |
|---|---|---|
HTTP cache (Default/Cache) |
stays on this computer | a copy of what sites serve; refilled on the next visit |
compiled-code cache (Default/Code Cache) |
stays | V8 rebuilds it from the scripts |
GPU / Dawn caches (Default/GPUCache, Dawn*) |
stays | keyed to this machine’s GPU and driver |
sites’ service-worker caches (Default/Service Worker/CacheStorage) |
stays | measured on the owner’s profile by each entry’s URL and response type: scripts, styles, HTML shells, images — 0.02 MiB of anything else; a worker refills its cache |
old_<cache>_NNN |
nowhere (A209) |
Chrome’s garbage |
service-worker REGISTRATIONS and SCRIPTS (Database, ScriptCache) |
travel | push subscriptions live there, and Chrome drops a registration whose script is missing |
cookies, Local / Session Storage, IndexedDB, File System, extensions’ state, history, preferences, Local State, vflin-fp-config.json |
travel | this is the person |
What stays is still sealed and still comes back on THIS computer. pack seals the caches beside the blob as
local-cache + local-cache.sig — a DEK of their own wrapped under the account’s Master Key, AEAD, signed by this
device, the blob’s own primitives — and materialize brings them back before the folder goes live, so a start here is
as warm as before (A98’s reason for keeping the cache stands). The cloud bundle, the export file and another machine
see only the blob: a profile arrives elsewhere with a cold cache, as a browser on a new device does. A local cache that
is missing, damaged, another profile’s or signed by a key this account does not hold is simply not restored — a cold
start, never a failed one — and its plaintext staging folder is wiped either way. A blob sealed before A210 still
carries its caches inside; those win over the local ones.
Measured through the product (A210 report): a profile signed in to a site on loopback that recognises the person
(a cookie the server reads, localStorage and IndexedDB the page reads) and fills every cache group — the seal: 0.1
MB travels, 9.3 MB stays (it was one 9.4 MB blob); the export file 0.06 MB. Started again on the same home: the site
recognises the person and none of the six cached scripts is fetched again; the worker’s cache is back. Imported on a
CLEAN home of another account: the server reads the same cookie, the page reads the same localStorage and IndexedDB,
and all six scripts are fetched again (a cold cache). On the owner’s heaviest profile the same rule leaves ≈ 41 MiB
packed travelling of the 659 its export carried (137 MiB raw of 1 405).
2. Lifecycle state machine
create / import
│
┌─────▼──────┐ pull(blob) ┌──────────────┐ materialize ┌───────────────┐
│ CLOUD_ONLY │────────────────►│ LOCAL_CACHED │────────────►│ MATERIALIZED │
└─────▲──────┘ └──────▲───────┘ └──────┬────────┘
│ evict │ pack │ start
│ │ ▼
┌───────┴────────┐ upload ┌──────┴───────┐ stop ┌──────────────┐
│ SYNCING_UPLOAD │◄───────────────│ DIRTY │◄────────────│ RUNNING │
└────────────────┘ └──────────────┘ └──────────────┘
remote newer │ ▲
▼ │ resolve
┌────────────────┐ ┌──────┴──────┐ lock held elsewhere
│ SYNCING_DOWNLOAD│ │ CONFLICT │◄────── LOCKED_REMOTE
└────────────────┘ └─────────────┘
State definitions:
CLOUD_ONLY— only the blob exists in cloud; nothing local.LOCAL_CACHED— blob present locally, not unpacked.MATERIALIZED— live dir exists, not running.RUNNING— a core is using the dir (exclusive; no sync may touch it).DIRTY— stopped with local changes not yet uploaded.SYNCING_UPLOAD/DOWNLOAD— transfer in progress.CONFLICT— local and remote diverged (both changed since common base).LOCKED_REMOTE— another device holds the run/edit lock.
3. Encryption & signing (zero-knowledge, multi-KEK — ADR-0008)
- Envelope hierarchy: each profile payload is AEAD-encrypted with a random per-profile Data Key (DEK). The DEK is wrapped independently by multiple KEKs — any one unwraps it. Server stores only ciphertext + the wrapped-DEK copies → server cannot decrypt on any path.
- KEK factors: password (Argon2id) + a mandatory Recovery Key (offline emergency kit); optional passkey (WebAuthn PRF) and OS keychain; team profiles add an org-key KEK (public-key wrap) for admin-assisted recovery — still zero-knowledge to the server.
- AEAD (AES-256-GCM or XChaCha20-Poly1305) for payload; signature (Ed25519) over manifest+ciphertext
for tamper/anti-corruption detection (the
.sig). - Recovery: lose one factor → recover via another. Lose all factors → lose the profiles (honest E2EE floor). No server-side escrow. Details: KEY_RECOVERY_COMPARISON.md, SECURITY_POLICY §4.
- A preview from the head is not verified — so the record says whose it is (
A220, ISSUE-710). A new computer shows the account’s profiles from each copy’s first bytes (A219, ISSUE-706): the manifest and its sealed record, before the signature, which covers the whole blob, can be checked. The manifest’sprofileIdis cleartext, so the sealed record carriesprofile_idtoo, under the profile’s DEK; a preview takes everything only from a record sealed for the profile it was fetched for, refuses a head that names another profile (and does not read it again while the account’s list names the same copy), and from a record sealed beforeA220takes its labels only – name, folder, tags, notes – until the verified copy arrives. The binding is written by every pack and kept by every record refresh; older programs ignore the field, and the blob signature (over the record’s ciphertext) is unchanged by it. Not in the manifest: a new manifest field changes the canonical bytes an older program re-serializes, and its signature check would fail.
3a. The exits and their bindings travel with the account (A221)
A profile’s record carries its exit’s ADDRESS; the login and the password live in the register’s vault entry
(proxy/<id>/…), and a start reads them through profile.proxy_id. So the register itself travels: its rows by their
own id (a random UUID — one exit everywhere) and the map «profile → exit», inside the account’s settings document, sealed
under the Master Key (exits_sync, beside settings_sync’s folder looks). Merged entry by entry by the time each changed,
a deletion as a tombstone, absence never a deletion; the same login at the same address under two ids becomes the smaller
id everywhere. What an exit measured stays on the computer that measured it. Never matched by address — one gateway
serves many logins. The section is compressed; the server’s 64 KiB settings request carries about 120 gateway-shaped
exits (512 KiB, about 960, once S58 is in production and the client follows; measured), past which the sync refuses in
words (SETTINGS_TOO_LARGE) and keeps everything here. The envelope’s ciphertext as an array of numbers is most of that
weight (ISSUE-720).
3b. A profile’s status travels, and so does the account’s list of statuses (A223)
The sealed record carries the profile’s status id (PortableRecord.status; None in records sealed before A223, which
older programs ignore), taken by every road that makes a profile from a record — a preview, the copy fetched, a file
import. The id names an entry of the account’s list (settings.profileStatuses), and that list travels in the account’s
settings document beside the folder looks (Travelling.profileStatuses), last writer wins the document as the looks do.
A document without the list — an older program’s, or a machine still on the built-in four — takes nothing away.
Since A224, each profile’s status travels in the account’s document too — and that is the senior source. The
record travels only with its copy, so a status set before A223 on a profile not opened since never left its computer
(the owner’s laptop on a224 showed none). The document now carries a map, profile id → {at, status}
(settings_sync::Travelling.profileStatusMap, status_map), merged entry by entry as the exits’ bindings are: the later
at wins, a tie keeps the account’s. A status known only from a record enters at at = 0, below anything a person sets;
a person’s change is noted with its time (status_map::note_change), and «no status» is an explicit null, so a
status taken off one computer does not come back from another’s old record. Every arrival (a preview, a copy, a file)
takes the map’s word when the map names the profile, and the record’s only when it does not. A list of statuses the
account’s copy lacks goes up with no edit (A224: a list made before A223 never marked the document changed).
3c. A wallet’s record does not travel without the wallet (A223, ISSUE-727)
A wallet lives in two places: its binding and unlock password in the sealed record, its keys in the payload
(Default/Local Extension Settings/<module id>/). The owner’s P2 reached his second computer with the first and not the
second, and the start made an empty wallet under the profile’s password. Four rules keep the two together (since A224 they count the wallets «Отвязать» KEPT in the keyring — detached_wallets — as
much as the attached ones: their keys are in the same payload):
- The module opens a wallet, it never makes one (module patch
0009): with an empty storage the early unlock saysnot-bootedand writes nothing. Seeding goes through the controller’s ownboot, never through the unlock. - A start does not open a wallet whose data is not in the copy — read from the unpacked folder, said in the journal.
- A push refuses
WALLET_NOT_IN_COPY(409) when the record names a set-up wallet and the copy’s seal found that the wallet is not in it. The daemon’s one seal reads the module’s own storage off the live folder before it wipes it (vflin_wallet_rabby::wallet_in_storage, read only: Chrome’s LevelDB log for that extension, two of Rabby’s keys –keyringStateandpreference.currentAccount) and notes the answer beside the copy (payload.wallet, local, dropped when a copy is taken from the cloud). No – no storage, or a keyring with no keys (a password and no address: what an older module made on the owner’s second computer) – refuses the push; the profile’s wallet – keys, and the record’s address current – lets it go; cannot tell – a damaged or foreign record, a table beside the log or more than one log (which writes are newest is not read here; an old log left beside a table can hold a keyring a newer one replaced – Codexkeys-2026-10-31T035404Z), keys under another address – drops the note and is said in the journal, never taken for «no». The push decrypts nothing, and a locked daemon checks it too. A refusal in the sync ledger, asked again after the next seal. - A copy taken from the cloud keeps the one it replaces (
blob.prev, as a seal does).
Measured (core 157, nine real storages from vflin-wallet-rabby/tests/rabby_popup_states.rs): empty, a password
and no address (open and locked), an empty one started the product’s way (twice) – all «no»; a seeded wallet over
four starts – «the profile’s wallet». In all nine every write is in the log, no table at all: the log grows about
9.5 KB a start, and LevelDB turns it into tables past its 4 MB buffer – some 400 starts of one wallet. A storage with
a table is «cannot tell» here: reading it needs the manifest (which log is live) and the tables, compressed with
snappy – a new crate, waiting for the owner’s word (ISSUE-727).
What rule 2 does not catch. The start checks only for the storage’s FILES: a copy that ran once without its wallet has them, and only rule 1 keeps the module from making an empty wallet there. Rule 3 reads the storage, so such a copy – and the owner’s P2 as it is now – does not travel.
4. Sync protocol
- Lock:
sync.lock.acquire(id)before upload/edit. Locks are leased (TTL) + owner-stamped. - Version vectors: each blob carries
{deviceId: counter}; compare to detect fast-forward vs divergence. - Upload: pack → encrypt → sign → multipart upload to object storage → commit manifest in metadata DB.
- Download: fetch blob → verify sig → decrypt → materialize on demand.
- Conflict: if remote advanced beyond local base while local is DIRTY →
CONFLICT. Default resolution: last-writer-wins guarded by lock; CONFLICT keeps both blobs (a/b) for manual pick. What is built (A224, the owner’s #81) — the manual pick,cloud.resolve. Nothing resolved a conflict before it: the cloud refuses a copy that does not dominate its own, a pull keeps what is here, and the contract’s «resolve withcloud.pull/cloud.push» could not. One choice, three answers:cloud— the cloud’s copy and its record become this computer’s (the fieldscloud.onboardtakes; this computer’s start pages, launch arguments and extensions stay);here— this computer’s copy is resealed past the cloud’s (each counter the larger, its own one past it — ADR-0016 §3) and sent over it;keptCopy— the copy the last choice kept aside is the main one again, everywhere. The copy not chosen is kept aside on THIS computer —blob.kept+blob.sig.kept+kept.json(who sealed it, its vector, when), the note written last so a keeping cut short leaves none. No seal replaces it —blob.prevwould be gone at the next close, and the window’s «не потеряется» would be false — it stays until the next choice for the profile or its deletion (discardtakes it). Only at rest, under the launch lock, with the account unlocked;hereandcloudonly while the cloud says the two changed apart. A failed step puts back the copy here and the kept one as they were. What it does not do: the other computer keeps its own copy until its next start takes the main one; the kept copy is on the computer where the choice was made, nowhere else; a cloud copy taken by an ordinary pull (newer) still brings its bytes and NOT its record (name, notes…), as before. And the rows nothing named (A224, the owner’s laptop:by sync {"synced": 2, "unknown": 88}): copies fetched in the background by a build beforeA220were never written into the sync ledger, and only a start or a seal writes a row. The tick now asks up to five such profiles a minute (at rest, sealed, in the account’s cloud list, no standing) the pull’s read-only question and writessynced/conflict;newerhas no standing in the ledger, so such a row stays as it is until its start takes the copy. - Eviction: LOCAL_CACHED/MATERIALIZED can be evicted to CLOUD_ONLY under disk pressure (LRU), never if DIRTY/RUNNING.
5. Local-first guarantee
With no cloud account, everything works: profiles live as local blobs + dirs, lifecycle minus the SYNCING/LOCKED_REMOTE states. Cloud is an additive layer, never a dependency (ARCHITECTURE.md §1.3).
5b. The export file — ONE file, no encryption, any account opens it (A174, ADR-0050)
Read ADR-0050 before changing anything here; this is a recorded decision of the owner’s, with the objection to it recorded beside it. He said what the export button is for — «это должен быть файл, чтобы кому-то другому дать, и он с любого акка импортнёт» — coordination objected that a profile carries cookies, live sessions, saved passwords and 2FA secrets, and he confirmed and narrowed it: encryption stays for the store on disk and for everything that travels through our server between machines of one account, and the export file has none.
| the export file | the sealed blob | |
|---|---|---|
| name | <profile>.vflinx |
<profile>.vflin (written before A174) |
| first bytes | VFLINX1 |
VFB1 |
| header | plaintext JSON: FIRST a notice in plain English and Russian — «NOT ENCRYPTED… / НЕ ЗАШИФРОВАН…», what whoever has the file has, written by the writer from what rides (A209) — then format, formatVersion, the record, the two secrets, the archive’s hash |
cleartext manifest, signed; the record sealed under the DEK |
| payload | the user-data archive, zstd, plaintext |
the same archive, zstd, AEAD under a per-profile DEK |
| who opens it | anyone | an account whose keyring unwraps that profile’s DEK |
| what proves it | a hash: the bytes arrived whole. Not who wrote them — the file is unsigned | an Ed25519 signature over the manifest and the ciphertext |
Three ways it names itself, because confusing the two is the one mistake that costs data, and either a
person or our own import could make it: the extension, the magic, and the header’s own format /
formatVersion. plain_export::sniff is the single place that decides which a file is, from its head alone.
Two bridges, not three (§6): BlobStore::write_plain_export is blob→file and
BlobStore::adopt_plain_archive is file→dir, both beside materialize in the store rather than copied into
the daemon. The second refuses a directory that already has content instead of writing over it.
The import mints a fresh id, and that is load-bearing twice over: a file meant for someone else must
import on any account and any number of times, which a travelling id would refuse the second time; and the
sender’s replica counters must never enter the receiver’s version vector, or the profile’s first cloud push
reads as diverged with no path back to convergence (ADR-0016). It therefore always CREATES — a plaintext file
has no author, so it cannot prove it is this profile’s own backup, and letting an id in such a header pick a
destructive path is the defect class this repo keeps paying for. The byte-identical Trash restore (A111)
stays on the sealed path, which can prove it.
The profile is back under protection before the call returns (ADR-0050 §3): the payload becomes the new profile’s live directory and is sealed at once by the ordinary pack, under the RECEIVING account’s key, with the wallet password and the OS-crypt key the file carried stored where this device keeps its own. All or nothing — a failure at any step destroys the record, the tree and anything sealed.
Nothing may call this file protected (ADR-0050 §5, invariant 6): no lock on an icon, no «зашифрован», no
«безопасно». profile.file.export’s answer carries encrypted: false to be read, and a test over this
crate’s source fails if the export path’s own sentences start claiming otherwise.
6. Invariants (enforced by Profile Lifecycle, never bypassed)
- A
RUNNINGprofile is never packed/uploaded/evicted. - Sync never edits a live
user-data-dir(DO_NOT_DO.md). - The local cache never travels (
A210):local-cacheis written and read only bypackandmaterializeon this computer — not byread_cloud_bundle,export_bundle_to,write_plain_exportor anything that sends bytes. - Materialize/pack are the only dir↔blob conversions — plus the two the export file needs (
A174, §5b):write_plain_export(blob→file) andadopt_plain_archive(file→dir), which live in the store besidematerializeprecisely so they are counted and not scattered. - “No plaintext profile leaves the device” is overridden for ONE operation, deliberately (
A174, ADR-0050):profile.file.export, by the owner’s decision, on his data, for handing a profile to someone else. It holds everywhere else without exception — the store on disk, every byte through our server, team shares. Written here rather than quietly dropped, because an invariant that is false in one place and still listed as absolute is how the next change makes it false in a second. - Every state transition emits a
profile.state_changedevent + (for dangerous ones) an audit entry. - Materialized = the only plaintext window. While
MATERIALIZED/RUNNINGtheuser-data-diris plaintext on disk (Chromium needs it). Onpack/evict the dir is securely wiped; the window is kept minimal. (Local-compromise residual risk: THREAT_MODEL §4.) SinceA176(ISSUE-474) the wipe is no longer on the save’s path: once the new pair is durable and verified, the live dir is renamed touser-data.wipe-<stamp>beside it and a thread overwrites every file with zeros, flushes each and removes it — the same wipe, moved off the time the person waits (it was 3.3–3.6 s of a 13.5 s seal on a heavy profile). Theuser-dataname is free at once, so a start right after materializes into a fresh folder. The daemon’s exit waits for its wipes (up to 60 s); a folder a process left half-wiped is wiped byrecover_interruptedat the next start. Compression is zstd level 3 on several threads (it was 9 on one: the largest step, 5.3–6.6 s of the same seal; level 3 costs +2.3 % size, and blobs sealed at 9 open unchanged). Every seal writes its step-by-step cost todaemon.log(seal <id>: … ms: before, walk, read, hash, compress, encrypt, sign, write, verify, rotate, wipe). SinceA210the head of that line (N files, X MB -> Y MB) is what TRAVELS, and the caches kept here are appended:; stays on this computer: N files, X MB -> Y MB, T ms. materializenever hands a raw path to remote clients. The path goes only to the local UI/owner context; CLI/MCP/script clients get an opaque handle, and any raw-path return is power mode + audited — a path would otherwise bypass the single control boundary.
Open questions
Archive determinism across OSes, partial/delta sync vs full-blob, lock TTL + steal policy, conflict UX, key recovery options (social/escrow?) → OPEN_QUESTIONS.md.