VFLIN

docs · docs/30-core/PROFILE_STORAGE_SYNC.md · перевод от 2026-10-28 · с английской ревизии 4505ff32b4d1

Профили и синхронизация

подписанные зашифрованные блобы; жизненный цикл; векторы версий; чего запущенный профиль никогда не делает

Назначение: определить жизненный цикл профиля, его формы на диске и при хранении и протокол синхронизации. Это модель, проверенная в поле отгруженными антидетект-продуктами (<uuid>.zip + <uuid>.sig, частичная локальная материализация) и формализованная здесь (ADR-0004).

1. Две формы профиля

  • Рабочая форма (runtime form): живой user-data-dir, с которым может работать Chromium (cookies, localStorage, IndexedDB…).
  • Форма для хранения и синхронизации: Profile Blob = manifest + payload:
    • payload = user-data-dir → детерминированный архив (tar/zip) → сжатие → шифрование (AEAD).
    • <uuid>.blob (шифртекст) + <uuid>.sig (подпись над шифртекстом и манифестом).
    • манифест (открытый, с защитой целостности): uuid, вектор версий, размер, версия ядра, открывавшего профиль последним, id отпечатка, createdAt, deviceId, contentHash.

Мосты между двумя формами: materialize (блоб → папка) и pack (папка → блоб). Это единственные разрешённые преобразования.

Чего блоб НЕ держит — и почему профиль всё равно целый (A98)

Живая папка профиля — по большей части не профиль. Измерено на настоящем парке (собственном парке владельца, 121 профиль, 2026-09-14): 2 948 MB живой папки самого тяжёлого профиля разложились так:

корзина MB доля в блобе?
личность — cookies, Local Storage, IndexedDB, расширения, связка ключей кошелька, Preferences, история, закладки 0.7 0.0% да, всегда
собственные загрузки Chrome — OptGuideOnDeviceModel (одна она 2 730 MB), component_crx_cache, TranslateKit, WasmTtsEngine, OnDeviceHeadSuggestModel, полезная нагрузка component-updater, Crashpad, BrowserMetrics* 2 944.4 99.9% нет
кэш — Cache, Code Cache, GPUCache, кэши Dawn 1.9 0.1% да, намеренно

pack отбрасывает среднюю корзину (EXCLUDE_TOP_LEVEL в archive.rs у vflin-crypto). Имя попадает под исключение по трём свойствам, и нужны все три: его пишет сам Chrome через component updater или подсистему сбоев и метрик, оно одинаково у всех и оно возвращается само. Исключение сверяет ТОЛЬКО первый сегмент пути, поэтому ничего внутри Default/ — а это и есть профиль — оно задеть не может.

Ключи доступа профиля — это личность, и они в блобе (A133, ISSUE-311 — F1b владельца: «сохраняться и даже передаваться экспортом»). С identity.passkeys: "profile" (патч 0059) собственный аутентификатор WebAuthn профиля держит свои ключи в <user-data-dir>/Vflin Passkeys — ФАЙЛЕ верхнего уровня, который не называет ни одно исключение, так что pack запечатывает его, как любой другой файл личности, а пакет переносит его в profile.file.export, в облачную синхронизацию и на другое устройство того же аккаунта. Измерено (тесты vflin-profiles …passkey_store_is_sealed…, демона …passkey_store_travels_in_the_profile_export и gated passkeys_live_gated на ядре s48 против тестового сайта владельца): после pack открытого текста нет, байт в байт после materialize и после переноса пакета в другое хранилище, и тот же ключ доступа входит после перезапуска и из другого хранилища. В архиве ничего менять не пришлось; не хватало переключателя (profile.update {passkeys}).

Кэш оставлен, и A98 перемерил это решение, а не переспорил его: браузер, у которого кэш пуст в начале каждой сессии, редок, а редкость — это то, на чём профиль ловят. На самом тяжёлом настоящем профиле вся корзина кэша была 1.9 MB, так что гигабайта для обмена здесь никогда не было.

Измерено, тот же вход, до и после:

профиль живая папка блоб до блоб после
самый тяжёлый у владельца 2 948 MB 2 380 MB (артефакт на его диске) 0.06 MB
профиль после двенадцати настоящих страниц 66.2 MB 28.89 MB 17.63 MB

Старым блобам миграция не нужна. pack работает при каждом profile.stop, поэтому профиль худеет в первый же раз, когда его открыли и закрыли, — шага пересборки нет, а парк, который никогда не открывают, держит старые байты, пока его не откроют. Профиль в покое — это только его блоб: pack надёжно стирает живую папку, так что вторая копия существует лишь пока профиль запущен.

Через шесть недель тяжёлый профиль — это И ЕСТЬ кэш (A209, ISSUE-571, замерено)

Таблица выше была верна на 2026-09-14 и больше не верна. Замерено на собственном экспорте владельца — его самом тяжёлом профиле (2026-10-24, p2): тот же архив, что несёт печать, и тот же уровень zstd (plain_export.rs говорит, что это одно и то же), прочитанный потоком, размеры сложены по папкам, ничего не распаковано на диск и ни один сайт не назван. «Сжато» — каждая группа через свой поток zstd уровня 3; группы в сумме дают 659.5 MiB против 658 у самого файла.

группа сырых MiB сжато MiB вернётся сама?
Default/Cache/Cache_Data — HTTP-кэш Chrome 459.7 256.6 да
Default/Cache/old_Cache_Data_000 — кэш, который Chrome сбросил и не успел удалить 231.4 213.6 это мусор: Chrome его удаляет
Default/Code Cache — скомпилированный JS / wasm 303.3 85.1 да
Default/Service Worker/CacheStorage — офлайн-кэши сайтов, 9 сайтов, самый большой 171 MiB сырых 261.2 63.1 да, при следующем визите
Default/Service Worker/ScriptCache — скрипты воркеров 51.4 15.4 да, но регистрацию без её скрипта Chrome выбрасывает — хранится вместе с регистрациями
Default/IndexedDB — базы сайтов, 13 сайтов, самая большая 23.8 MiB сырых 36.0 22.0 нет
Default/Sessions — открытые вкладки для восстановления 29.7 1.3 нет
кэши GPU / Dawn 11.7 0.1 да, привязаны к машине
всё остальное: cookies, пароли, Local / Session Storage, состояние расширений (связка ключей кошелька), история, значки сайтов, настройки, Local State, vflin-fp-config.json 20.2 2.3 нет
итого 1 404.6 659.5

Что синхронизация обязана нести — то, что само не вернётся, плюс скрипты воркеров по правилу «не уверен — храни»: ≈ 41 MiB сжатых из 659 (6 %) на самом тяжёлом профиле. Всё остальное в нём отрастает заново. Обычный профиль — совсем другой масштаб: по 663 печатям в журнале владельца медиана 0.00 MB, p90 0.10, p99 0.20, самая большая — 238.3 MB сжатых (449.8 сырых), другой его профиль, 2026-10-17; экспортированный в этом журнале ни разу не запечатывался (он живёт в другом хранилище). Значит, тяжёлых профилей два; по папкам замерен только экспортированный — второй это запечатанный блоб, и открыть его значит открыть данные владельца.

Мусор Chrome больше не ездит (A209, PENDING_DELETION в archive.rs): папка, которую Chrome переименовал в old_<cache>_<NNN> для удаления (net/disk_cache/cache_util.cc) на месте самого кэша, в упаковку не попадает — на этом профиле одно это 214 из 659 MiB сжатых.

Что ездит, а что остаётся на этом компьютере (A210, ISSUE-571)

Правило из брифа координации: если оно возвращается само, оно не ездит. Один обход папки даёт два архива (vflin_crypto::pack_dir_parts_timed, список — LOCAL_ONLY в archive.rs):

группа где почему
HTTP-кэш (Default/Cache) остаётся на этом компьютере копия того, что отдают сайты; заполнится при следующем визите
кэш скомпилированного кода (Default/Code Cache) остаётся V8 пересобирает его из скриптов
кэши GPU / Dawn (Default/GPUCache, Dawn*) остаётся привязаны к GPU и драйверу этой машины
кэши сервис-воркеров сайтов (Default/Service Worker/CacheStorage) остаётся замерено на профиле владельца по URL и типу ответа каждой записи: скрипты, стили, HTML-оболочки, картинки — 0.02 MiB чего-то другого; воркер сам заполняет свой кэш
old_<cache>_NNN нигде (A209) мусор Chrome
РЕГИСТРАЦИИ и СКРИПТЫ сервис-воркеров (Database, ScriptCache) ездят там живут push-подписки, а регистрацию без скрипта Chrome выбрасывает
cookies, Local / Session Storage, IndexedDB, File System, состояние расширений, история, настройки, Local State, vflin-fp-config.json ездят это и есть человек

То, что остаётся, по-прежнему запечатано и по-прежнему возвращается на ЭТОМ компьютере. pack запечатывает кэши рядом с блобом как local-cache + local-cache.sig — свой DEK, обёрнутый Master Key аккаунта, AEAD, подпись этого устройства, те же примитивы, что у блоба, — а materialize возвращает их до того, как папка оживёт, поэтому запуск здесь такой же тёплый, как раньше (довод A98 за сохранение кэша в силе). Облачный пакет, файл экспорта и другая машина видят только блоб: на другом месте профиль приходит с холодным кэшем, как браузер на новом устройстве. Локальный кэш, которого нет, который повреждён, принадлежит другому профилю или подписан ключом, которого у аккаунта нет, просто не восстанавливается — холодный запуск, но никогда не сорванный, — а его открытая промежуточная папка стирается в любом случае. Блоб, запечатанный до A210, ещё несёт кэши внутри; они берут верх над локальными.

Замерено через продукт (отчёт A210): профиль вошёл на сайт на loopback, который узнаёт человека (cookie, который читает сервер, localStorage и IndexedDB, которые читает страница), и заполнил все группы кэша — печать: ездит 0.1 MB, остаётся 9.3 MB (был один блоб на 9.4 MB); файл экспорта — 0.06 MB. Запущен снова в том же каталоге программы: сайт узнаёт человека, и ни один из шести закэшированных скриптов не скачан заново; кэш воркера вернулся. Импортирован в ЧИСТЫЙ каталог программы другого аккаунта: сервер читает тот же cookie, страница — те же localStorage и IndexedDB, а все шесть скриптов скачаны заново (холодный кэш). На самом тяжёлом профиле владельца то же правило оставляет в пути ≈ 41 MiB сжатых из 659, что вёз его экспорт (137 MiB сырых из 1 405).

2. Конечный автомат жизненного цикла

            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
        └────────────────┘                   └─────────────┘

Определения состояний:

  • CLOUD_ONLY — блоб существует только в облаке; локально ничего нет.
  • LOCAL_CACHED — блоб есть локально, не распакован.
  • MATERIALIZED — живая папка существует, профиль не запущен.
  • RUNNING — ядро работает с папкой (монопольно; синхронизация не может её трогать).
  • DIRTY — остановлен с локальными изменениями, ещё не загруженными.
  • SYNCING_UPLOAD/DOWNLOAD — идёт передача.
  • CONFLICT — локальная и удалённая версии разошлись (обе изменились после общей базы).
  • LOCKED_REMOTE — блокировку запуска/правки держит другое устройство.

3. Шифрование и подпись (zero-knowledge, несколько KEK — ADR-0008)

  • Иерархия конвертов: полезная нагрузка каждого профиля шифруется AEAD случайным ключом данных профиля (DEK). DEK независимо оборачивается несколькими KEK — любой из них его разворачивает. Сервер хранит только шифртекст и копии обёрнутого DEK → сервер не может расшифровать ни на одном пути.
  • Факторы KEK: пароль (Argon2id) + обязательный Recovery Key (офлайн-набор на экстренный случай); необязательные passkey (WebAuthn PRF) и связка ключей ОС; командные профили добавляют KEK ключа организации (обёртка открытым ключом) для восстановления с помощью администратора — по-прежнему zero-knowledge для сервера.
  • AEAD (AES-256-GCM или XChaCha20-Poly1305) для полезной нагрузки; подпись (Ed25519) над манифестом и шифртекстом — чтобы обнаружить подмену и повреждение (.sig).
  • Восстановление: потеряли один фактор → восстановите через другой. Потеряли все факторы → потеряли профили (честный предел сквозного шифрования). Никакого депонирования на сервере. Подробности: KEY_RECOVERY_COMPARISON.md, SECURITY_POLICY §4.
  • Предпросмотр по голове не проверен — поэтому запись говорит, чья она (A220, ISSUE-710). Новый компьютер показывает профили аккаунта по первым байтам каждой копии (A219, ISSUE-706): манифест и его запечатанная запись — раньше, чем можно проверить подпись, которая покрывает весь blob. profileId манифеста открыт, поэтому запечатанная запись тоже несёт profile_id, под DEK профиля; предпросмотр берёт всё только из записи, запечатанной для того профиля, ради которого её скачали, отказывает голове, которая называет другой профиль (и не читает её снова, пока список аккаунта называет ту же копию), а из записи, запечатанной до A220, берёт только подписи — имя, папку, теги, заметки — до прихода проверенной копии. Привязку пишет каждая упаковка и сохраняет каждое обновление записи; старые программы это поле пропускают, а подпись blob (над шифротекстом записи) от него не меняется. Не в манифесте: новое поле манифеста меняет канонические байты, которые старая программа сериализует заново, и её проверка подписи не сошлась бы.

3a. Выходы и их привязки едут вместе с аккаунтом (A221)

Запись профиля несёт АДРЕС его выхода; логин и пароль лежат в записи хранилища реестра (proxy/<id>/…), и запуск читает их через profile.proxy_id. Поэтому едет сам реестр: его строки — по их собственному id (случайный UUID — один выход везде) и карта «профиль → выход», внутри документа настроек аккаунта, запечатанного под Master Key (exits_sync, рядом с видом папок из settings_sync). Слияние — запись за записью по времени, когда каждая менялась, удаление — надгробием, отсутствие никогда не удаление; один и тот же логин на одном адресе под двумя id везде становится меньшим id. То, что выход намерил, остаётся на компьютере, который мерил. Никогда не сопоставляется по адресу — один шлюз обслуживает много логинов. Раздел сжат; запрос настроек сервера в 64 KiB вмещает около 120 выходов вида шлюза (512 KiB — около 960, когда S58 будет на проде и клиент пойдёт следом; замерено), сверх этого синхронизация отказывает словами (SETTINGS_TOO_LARGE) и оставляет всё здесь. Шифротекст конверта как массив чисел — большая часть этого веса (ISSUE-720).

3b. Статус профиля едет с ним, и список статусов аккаунта тоже (A223)

Запечатанная запись несёт id статуса профиля (PortableRecord.status; None в записях, запечатанных до A223, — старые программы его не читают), и его берёт каждая дорога, которая делает профиль из записи: предпросмотр, скачанная копия, импорт файла. Id называет строку списка аккаунта (settings.profileStatuses), а сам список едет в документе настроек аккаунта рядом с видом папок (Travelling.profileStatuses); документ, как и вид папок, выигрывает последний записавший. Документ без списка — от старой программы или с машины, где всё ещё встроенные четыре статуса, — ничего не отнимает.

С A224 статус каждого профиля едет и в документе аккаунта — и это старший источник. Запись едет только со своей копией, поэтому статус, поставленный до A223 на профиле, который с тех пор не открывали, так и не покинул свой компьютер (ноутбук владельца на a224 не показал ни одного). Теперь документ несёт карту «id профиля → {at, status}» (settings_sync::Travelling.profileStatusMap, status_map), и она сливается запись за записью, как привязки выходов: выигрывает более позднее at, при равенстве остаётся аккаунтное. Статус, известный только из записи, входит с at = 0, ниже всего, что ставит человек; изменение человека записывается со своим временем (status_map::note_change), и «нет статуса» — это явный null, поэтому статус, снятый на одном компьютере, не возвращается со старой записью другого. Каждое появление профиля (предпросмотр, копия, файл) берёт слово карты, когда карта называет профиль, и слово записи — только когда не называет. Список статусов, которого нет в копии аккаунта, уходит наверх без правки (A224: список, сделанный до A223, никогда не помечал документ изменённым).

3c. Запись кошелька не едет без самого кошелька (A223, ISSUE-727)

Кошелёк живёт в двух местах: его привязка и пароль разблокировки — в запечатанной записи, его ключи — в полезной нагрузке (Default/Local Extension Settings/<module id>/). P2 владельца доехал до его второго компьютера с первым и без второго, и запуск сделал пустой кошелёк под паролем профиля. Четыре правила держат их вместе (с A224 они считают кошельки, которые «Отвязать» ОСТАВИЛО в связке ключей, — detached_wallets — наравне с привязанными: их ключи лежат в той же полезной нагрузке):

  1. Модуль открывает кошелёк и никогда его не создаёт (патч модуля 0009): при пустом хранилище ранняя разблокировка отвечает not-booted и ничего не пишет. Заполнение идёт через собственный boot контроллера, никогда через разблокировку.
  2. Запуск не открывает кошелёк, чьих данных нет в копии — это читается из распакованной папки и пишется в журнал.
  3. Отправка отказывает WALLET_NOT_IN_COPY (409), когда запись называет настроенный кошелёк, а запечатывание копии нашло, что кошелька в ней нет. Единственное запечатывание демона читает собственное хранилище модуля из живой папки, прежде чем её стереть (vflin_wallet_rabby::wallet_in_storage, только чтение: журнал LevelDB Chrome для этого расширения, два ключа Rabby – keyringState и preference.currentAccount), и кладёт ответ рядом с копией (payload.wallet, локально, сбрасывается, когда копия берётся из облака). Нет – хранилища нет или в связке ключей нет ключей (пароль без адреса: то, что старый модуль сделал на втором компьютере владельца) – отправка отклоняется; кошелёк профиля – ключи есть и адрес записи актуален – отправка проходит; не понять – повреждённая или чужая запись, таблица рядом с журналом или больше одного журнала (какие записи новее, здесь не читается; старый журнал, оставшийся рядом с таблицей, может держать связку, которую новая уже заменила – Codex keys-2026-10-31T035404Z), ключи под другим адресом – отметка сбрасывается, это пишется в журнал и никогда не считается за «нет». Отправка ничего не расшифровывает, и заблокированный демон тоже это проверяет. Отказ ложится в журнал синхронизации, и вопрос задаётся снова после следующего запечатывания.
  4. Копия, взятая из облака, сохраняет ту, которую заменяет (blob.prev, как и запечатывание).

Замерено (ядро 157, девять настоящих хранилищ из vflin-wallet-rabby/tests/rabby_popup_states.rs): пустое, пароль без адреса (открытое и запертое), пустое, запущенное так, как запускает продукт (дважды), – везде «нет»; заполненный кошелёк за четыре запуска – «кошелёк профиля». Во всех девяти каждая запись лежит в журнале, таблиц нет вовсе: журнал растёт примерно на 9.5 KB за запуск, а LevelDB превращает его в таблицы, когда он перерастает буфер в 4 MB, – это около 400 запусков одного кошелька. Хранилище с таблицей здесь – «не понять»: чтобы его прочитать, нужны манифест (какой журнал живой) и таблицы, сжатые snappy, – это новый крейт, и он ждёт слова владельца (ISSUE-727).

Чего правило 2 не ловит. Запуск проверяет только ФАЙЛЫ хранилища: у копии, которая один раз запускалась без своего кошелька, они есть, и там только правило 1 не даёт модулю создать пустой кошелёк. Правило 3 читает само хранилище, поэтому такая копия – и P2 владельца в нынешнем виде – не едет.

4. Протокол синхронизации

  1. Блокировка: sync.lock.acquire(id) перед загрузкой или правкой. Блокировки выдаются в аренду (TTL) и помечены владельцем.
  2. Векторы версий: каждый блоб несёт {deviceId: counter}; сравнение показывает, перемотка это вперёд или расхождение.
  3. Загрузка: pack → шифрование → подпись → multipart-загрузка в объектное хранилище → фиксация манифеста в базе метаданных.
  4. Скачивание: получить блоб → проверить подпись → расшифровать → материализовать по требованию.
  5. Конфликт: если удалённая версия ушла дальше локальной базы, пока локальная в DIRTY → CONFLICT. Разрешение по умолчанию: побеждает последний записавший, под защитой блокировки; CONFLICT сохраняет оба блоба (a/b) для ручного выбора. Что построено (A224, #81 владельца) — ручной выбор, cloud.resolve. До него конфликт не разрешало ничто: облако отказывает копии, которая не старше его собственной, загрузка оставляет то, что здесь, а «разрешайте через cloud.pull / cloud.push» из контракта не могло. Один выбор, три ответа: cloud — копия облака и её запись становятся копией этого компьютера (поля, которые берёт cloud.onboard; стартовые страницы, аргументы запуска и расширения этого компьютера остаются); here — копия этого компьютера перезапечатывается старше облачной (каждый счётчик — больший из двух, свой — на шаг дальше; ADR-0016 §3) и отправляется поверх неё; keptCopy — копия, отложенная прошлым выбором, снова становится главной везде. Невыбранная копия откладывается на ЭТОМ компьютере — blob.kept + blob.sig.kept + kept.json (кто запечатал, её вектор, когда); заметка пишется последней, поэтому прерванное откладывание не оставляет ничего. Её не заменяет ни одно запечатывание — blob.prev пропал бы при следующем закрытии, и «не потеряется» в окне стало бы неправдой, — она лежит до следующего выбора для профиля или его удаления (её забирает discard). Только в покое, под блокировкой запуска, при открытом аккаунте; here и cloud — только пока облако говорит, что копии разошлись. Неудавшийся шаг возвращает копию здесь и отложенную как были. Чего он не делает: другой компьютер держит свою копию, пока его следующий запуск не возьмёт главную; отложенная копия есть только на компьютере, где сделан выбор; облачная копия, взятая обычной загрузкой (newer), по-прежнему приносит свои байты, но НЕ свою запись (имя, заметки…). И строки, которых никто не называл (A224, ноутбук владельца: by sync {"synced": 2, "unknown": 88}): копии, скачанные в фоне сборкой до A220, никогда не записывались в журнал синхронизации, а строку пишут только запуск или запечатывание. Теперь тик задаёт до пяти таких профилей в минуту (в покое, запечатанных, в облачном списке аккаунта, без своей строки) вопрос загрузки, только для чтения, и пишет synced / conflict; у newer нет своего места в журнале, поэтому такая строка остаётся как есть, пока её запуск не возьмёт копию.
  6. Вытеснение: LOCAL_CACHED/MATERIALIZED могут вытесняться в CLOUD_ONLY при нехватке места на диске (LRU), но никогда — в DIRTY/RUNNING.

5. Гарантия local-first

Без облачного аккаунта работает всё: профили живут как локальные блобы и папки, жизненный цикл — без состояний SYNCING/LOCKED_REMOTE. Облако — добавочный слой и никогда не зависимость (ARCHITECTURE.md §1.3).

5b. Файл экспорта — ОДИН файл, без шифрования, открывается любым аккаунтом (A174, ADR-0050)

Прежде чем что-то здесь менять, прочитайте ADR-0050: это записанное решение владельца, и возражение против него записано рядом. Он сказал, для чего кнопка экспорта, — «это должен быть файл, чтобы кому-то другому дать, и он с любого акка импортнёт», — координация возразила, что профиль несёт cookies, живые сессии, сохранённые пароли и секреты 2FA, и он подтвердил и сузил: шифрование остаётся для хранилища на диске и для всего, что ездит через наш сервер между машинами одного аккаунта, а у файла экспорта его нет.

файл экспорта запечатанный блоб
имя <profile>.vflinx <profile>.vflin (так писали до A174)
первые байты VFLINX1 VFB1
заголовок открытый JSON: СНАЧАЛА notice простым английским и русским — «NOT ENCRYPTED… / НЕ ЗАШИФРОВАН…», что получает тот, у кого файл; пишет его тот, кто пишет файл, из того, что в нём едет (A209), — затем format, formatVersion, запись, два секрета, хеш архива открытый манифест, подписанный; запись запечатана под DEK
содержимое архив user-data, zstd, открытым текстом тот же архив, zstd, AEAD под DEK профиля
кто открывает кто угодно аккаунт, чья связка ключей разворачивает DEK этого профиля
что это доказывает хеш: байты дошли целиком. Не то, кто их написал — файл не подписан подпись Ed25519 над манифестом и шифртекстом

Три способа, которыми он себя называет, потому что спутать эти два — единственная ошибка, которая стоит данных, и её может сделать и человек, и наш собственный импорт: расширение, магия и собственные format / formatVersion заголовка. plain_export::sniff — единственное место, которое решает, что за файл перед ним, по одной его голове.

Два моста, а не три (§6): BlobStore::write_plain_export — блоб→файл, BlobStore::adopt_plain_archive — файл→папка, оба рядом с materialize в хранилище, а не скопированы в демон. Второй отказывает папке, в которой уже что-то есть, вместо того чтобы писать поверх.

Импорт выдаёт свежий id, и это несущая деталь дважды: файл, предназначенный кому-то другому, должен импортироваться в любой аккаунт и сколько угодно раз, а путешествующий id во второй раз получил бы отказ; и счётчики реплик отправителя никогда не должны попасть в вектор версий получателя, иначе первая облачная отправка профиля читается как разошедшаяся без пути назад к сходимости (ADR-0016). Поэтому импорт всегда СОЗДАЁТ — у открытого файла нет автора, он не может доказать, что это резервная копия именно этого профиля, а позволить id из такого заголовка выбрать разрушительный путь — тот класс дефектов, за который это хранилище платит снова и снова. Побайтовое восстановление из корзины (A111) остаётся на запечатанном пути, который может это доказать.

Профиль снова под защитой до того, как вызов вернётся (ADR-0050 §3): содержимое становится живой папкой нового профиля и сразу запечатывается обычным pack, под ключом ПРИНИМАЮЩЕГО аккаунта, а пароль кошелька и ключ OS-crypt, которые нёс файл, сохраняются там, где это устройство хранит свои. Всё или ничего — сбой на любом шаге уничтожает запись, дерево и всё запечатанное.

Ничто не может называть этот файл защищённым (ADR-0050 §5, инвариант 6): ни замка на значке, ни «зашифрован», ни «безопасно». Ответ profile.file.export несёт encrypted: false, чтобы его читали, и тест над исходниками этого крейта падает, если собственные фразы пути экспорта начнут утверждать обратное.

6. Инварианты (их соблюдает Profile Lifecycle, в обход — никогда)

  • Профиль в RUNNING никогда не упаковывается, не загружается и не вытесняется.
  • Синхронизация никогда не правит живой user-data-dir (DO_NOT_DO.md).
  • Локальный кэш никогда не ездит (A210): local-cache пишут и читают только pack и materialize на этом компьютере — не read_cloud_bundle, не export_bundle_to, не write_plain_export и ничто, что отправляет байты.
  • Materialize/pack — единственные преобразования папка↔блоб — плюс два, нужных файлу экспорта (A174, §5b): write_plain_export (блоб→файл) и adopt_plain_archive (файл→папка); они живут в хранилище рядом с materialize именно затем, чтобы их можно было пересчитать, а не искать по коду.
  • «Ни один профиль не покидает устройство открытым текстом» отменено для ОДНОЙ операции, намеренно (A174, ADR-0050): profile.file.export, по решению владельца, на его данных, чтобы передать профиль кому-то другому. Во всём остальном правило держится без исключений — хранилище на диске, каждый байт через наш сервер, командные передачи. Записано здесь, а не тихо выброшено, потому что инвариант, ложный в одном месте и всё ещё записанный как абсолютный, — это то, как следующее изменение делает его ложным во втором.
  • Каждый переход состояния порождает событие profile.state_changed и (для опасных) запись аудита.
  • Materialized = единственное окно открытого текста. Пока профиль в MATERIALIZED/RUNNING, user-data-dir лежит на диске в открытом виде (он нужен Chromium). При pack/вытеснении папка надёжно стирается; окно держится минимальным. (Остаточный риск при компрометации машины: THREAT_MODEL §4.) С A176 (ISSUE-474) стирание больше не на пути сохранения: как только новая пара надёжно записана и проверена, живая папка переименовывается в user-data.wipe-<stamp> рядом, а поток перезаписывает каждый файл нулями, сбрасывает его на диск и удаляет — то же стирание, вынесенное из времени, которое человек ждёт (было 3.3–3.6 с из 13.5 с печати тяжёлого профиля). Имя user-data свободно сразу, так что запуск сразу после материализуется в свежую папку. Выход демона ждёт своих стираний (до 60 с); папку, которую процесс оставил стёртой наполовину, стирает recover_interrupted при следующем запуске. Сжатие — zstd уровня 3 в несколько потоков (было 9 в один: самый большой шаг, 5.3–6.6 с той же печати; уровень 3 стоит +2.3 % размера, а блобы, запечатанные на 9, открываются без изменений). Каждая печать пишет свою стоимость по шагам в daemon.log (seal <id>: … ms: before, walk, read, hash, compress, encrypt, sign, write, verify, rotate, wipe). С A210 голова этой строки (N files, X MB -> Y MB) — это то, что ЕДЕТ, а кэши, оставленные здесь, дописываются: ; stays on this computer: N files, X MB -> Y MB, T ms.
  • materialize никогда не отдаёт сырой путь удалённым клиентам. Путь получает только локальный интерфейс / контекст владельца; клиенты CLI/MCP/скриптов получают непрозрачный дескриптор, а любая выдача сырого пути — это power mode + аудит: иначе путь обошёл бы единую границу управления.

Открытые вопросы

Детерминизм архивов между ОС, частичная/дельта-синхронизация против полного блоба, TTL блокировки и политика перехвата, UX конфликтов, варианты восстановления ключей (социальное/депонирование?) → OPEN_QUESTIONS.md.