Skip to content

Архитектурные решения

Этот документ фиксирует ключевые архитектурные решения (ADR) и известные компромиссы проекта как состояние «как построено». Цель — прозрачность для разработчиков и AI-ассистентов.

Архитектурные решения

ADR-1: Concurrency — Arc<Mutex<T>> без формальной стратегии порядка блокировок

Контекст. Все менеджеры обёрнуты в Arc<Mutex<T>>. SharingManager держит ссылки на 5 других менеджеров, PairingManager — на 4.

Риск. Теоретически возможен deadlock, если два потока захватывают мьютексы в разном порядке.

Почему сейчас это работает:

  1. Сервисный слой — единственный вызывающий. Сервисные функции (*_svc) захватывают мьютексы последовательно: lock() → операция → drop(guard) → следующий lock(). Два мьютекса одновременно не удерживаются.
  2. Фоновые задачи изолированы. SharingManager и PairingManager отправляют команды через mpsc-каналы. Фоновый tokio::spawn работает с собственным экземпляром менеджеров и не конкурирует с сервисным слоем.
  3. Отсутствие вложенных блокировок. Менеджеры не вызывают друг друга напрямую — cross-manager вызовы происходят только в сервисном слое.

Рекомендация. Если появится необходимость удерживать два мьютекса одновременно — ввести формальный порядок: Identity → Device → Vault → Space → Cloud → Pairing → Sharing (по порядку инициализации).


ADR-2: Миграции БД — CREATE TABLE IF NOT EXISTS + ALTER TABLE

Контекст. Проект использует SQLite без миграционного фреймворка (diesel, sqlx). Таблицы создаются в конструкторах менеджеров через CREATE TABLE IF NOT EXISTS, а добавление колонок — через ALTER TABLE ADD COLUMN с PRAGMA table_info проверкой.

Компромисс:

ПлюсМинус
Нулевая зависимость, простотаНет версионирования схемы
Работает для additive-измененийНе поддерживает destructive-изменения (DROP COLUMN, переименования)
Нет runtime-миграций при стартеНет отката миграций

Когда сломается:

  • Изменение типа колонки
  • Удаление или переименование колонки
  • Изменение constraint'ов (UNIQUE, FK)

Рекомендация. Для серьёзных schema-изменений — использовать паттерн "create new table → copy data → drop old → rename". Для production-релиза рассмотреть refinery или sqlx::migrate!.


ADR-3: State sync — "CRDT-inspired", но не CRDT

Контекст. Синхронизация между устройствами использует:

  • SyncDigest — blake3-хеш отсортированных ID (устройства, пространства, облака)
  • SyncSnapshot — полный снимок для merge
  • SyncUpdate — инкрементальные обновления
  • Tombstones — маркеры удаления (TTL 30 дней)

Это НЕ полноценный CRDT, потому что:

  • Нет vector clock'ов — невозможно определить causal ordering
  • Merge = "добавить всё, чего нет" (OR-Set семантика для добавлений)
  • Удаления — через tombstones (2P-Set семантика)
  • Конфликты записи в файлы — отдельная подсистема (sync_conflicts)

Почему этого достаточно:

  1. Устройства одного владельца. Конфликты маловероятны — обычно один человек работает на одном устройстве.
  2. Spaces/devices — append-mostly. Создание пространств и добавление устройств — идемпотентные операции.
  3. Файловые конфликты — отдельно. Для файлов используется hash-comparison + explicit conflict resolution.

Известное ограничение: Если устройство offline > 30 дней (tombstone TTL), оно может "воскресить" удалённые пространства при синхронизации. Это acceptable trade-off: длительный offline — edge case, а tombstones нельзя хранить вечно.


ADR-4: Гибридное хранилище — VaultManager + StrongholdManager

Контекст. Проект использует два менеджера для криптографических операций:

VaultManagerStrongholdManager
ХранениеSQLite (encrypted secrets)IOTA Stronghold snapshot
Защита памятиZeroizing (зануление при drop)Guard pages, mlock, mprotect, non-contiguous memory
Модель доступаСекреты расшифровываются в RAMVault: write-only, операции через Procedures
KDFArgon2id (64 MB, 3 iter)Scrypt (production)
UX-фичиPIN, auto-lock, rate limiting, recovery phraseПароль

Почему два менеджера, а не один:

  • VaultManager — user-facing: PIN-код, auto-lock timeout, BIP39 recovery, rate limiting. Это UX-обёртка над persistent данными, не требующими memory-page защиты.
  • StrongholdManager — security-focused: ключи DeviceSignKey / DeviceBoxKey генерируются внутри Stronghold и никогда не покидают защищённую память. Все криптографические операции (подпись, X25519 DH, AEAD unwrap) вычисляются внутри Procedure.

Два пути для signing в зависимости от secret-type:

plantuml Diagram

Используемые сейчас возможности Stronghold:

  • GenerateKey (Ed25519 + X25519) — DeviceSignKey / DeviceBoxKey, генерация in-place.
  • PublicKey — извлечение pubkey'я уже-сгенерированного secret'а.
  • Ed25519Sign / X25519DiffieHellman / Hkdf / AeadDecrypt — chain для unwrap'а WrappedSpaceKey.
  • Single client с разными SecretType location'ами (фактически namespaces) — b"device_sign_key", b"device_box_key", b"libp2p_private_key", …

Неиспользованные возможности Stronghold:

  • BIP39Generate / BIP39Recover — recovery-фраза сейчас делается на VaultManager-стороне (BIP39 через bip39 crate).
  • SLIP10Derive (HD key derivation) — для будущей поддержки иерархических производных ключей (multi-account).
  • Multiple clients (полноценные namespaces) — текущий код использует один клиент kontinuumapp_client.
  • Key rotation primitives — DeviceSignKey/DeviceBoxKey сейчас one-shot per device; rotation потребует cert-chain extension.

Рекомендация. Текущий гибридный подход оптимален. Перенос всей логики в Stronghold потребует реализации rate limiting, auto-lock и PIN поверх snapshot-пароля — что сведёт к пересозданию VaultManager. См. подробное сравнение в StrongholdManager.


ADR-5: Pairing — «побеждает тот, у кого больше устройств»

Контекст. При паринге двух устройств нужно решить, чья identity / account_root_pubkey становится общей для пары — чтобы у пары осталась одна криптографическая идентичность аккаунта, а не две конфликтующие. Правило применяется к обоим flow'ам (см. ниже).

Алгоритм:

  1. Initiator отправляет device_count вместе с запросом
  2. Confirmer сравнивает со своим device_count
  3. Устройство с меньшим числом девайсов принимает identity / account_root другого
  4. При равенстве — confirmer принимает identity / account_root initiator'а

Обоснование:

  • Устройство с большим числом девайсов уже «распространило» свою identity / cert-chain — дешевле мигрировать одно новое устройство, чем N существующих.
  • При равенстве (обычно 1:1 при первом pairing) initiator «старше» — он активно инициировал связь.

Альтернативы (отклонены):

  • Всегда host/initiator — неинтуитивно, если у joiner'а 5 устройств.
  • Спросить пользователя — усложняет UX, пользователь не понимает разницу.

Как правило реализуется в разных flow'ах:

  • Legacy Bluetooth-style PIN-flow (/api/pairing/initiate + /api/pairing/confirm) — backend применяет правило автоматически: confirm_pairing сравнивает initiator_device_count со своим и выбирает direction (см. PairingManager::confirm_pairing case A/B). Frontend получает уже разрешённый результат.
  • Cross-sign flow (ADR-7) — joiner всегда наследует account_root_pubkey host'а (кто шлёт PairingJoinRequest — тот joiner). Правило применяется на UX-уровне: фронт сравнивает device_count'ы (через IdentityProof / KnownPeer.device_count) и предлагает пользователю инициировать join с того устройства, у которого их меньше. Backend не enforce'ит — потому что у joiner'а может быть валидная причина пойти в маленький аккаунт (например, начать чистый кейс). См. ADR-7 «коллизии с другим account_root_pubkey».

ADR-6: pending_signing_key — отложенное сохранение legacy identity-ключа

Применимость: только legacy identity_signing_key (Ed25519 на identity-уровне, до cert-chain). DeviceSignKey / DeviceBoxKey под slice 6 этот буфер не требуют — они генерируются после vault unlock'а внутри Stronghold.

Контекст. При создании identity генерируется legacy Ed25519 keypair (используется для meet/sharing-flow'ов, которые ещё не переехали полностью на cert-chain provenance). Signing key должен быть сохранён в Vault, но Vault может быть ещё не настроен (identity создаётся до vault setup в init flow).

Решение: IdentityManager.set_pending_signing_key() сохраняет ключ в памяти. При первом unlock_with_pin / setup_pin ключ автоматически сохраняется в Vault через take_pending_signing_key().

Риск: Если приложение крашится между create_identity и unlock_vault, legacy signing key теряется. Identity остаётся, но без возможности подписывать meet-envelope'ы.

Митигация:

  • create_identity вызывается при первом запуске, следующий шаг — setup PIN (vault creation)
  • Между ними — секунды (user flow: create identity → set PIN → done)
  • Если crash произошёл — при следующем запуске identity загружается, но vault всё ещё не настроен → пользователь проходит setup заново
  • Recovery phrase восстанавливает master key
  • DeviceSignKey / DeviceBoxKey сгенерируются при первом успешном unlock'е независимо от того, был ли pending ключ

ADR-7: Pairing cross-sign — cert-chain anchored на account_root_pubkey

Применимость: новый flow /api/pairing/join/initiate (slices 14–15). Сосуществует с ADR-5 legacy путём, постепенно заменяет его.

Контекст. Cross-sign pairing должен установить общее криптографическое доверие между устройствами одного аккаунта, не передавая приватный ключ по проводу. Решает headline-уязвимость legacy flow (apply_paired_identity копирует signing_key через wire).

Решение: cert-chain anchored в account_root_pubkey.

Все устройства одного аккаунта имеют разные per-device Stronghold-ключи (DeviceSignKey, DeviceBoxKey), но общий account_root_pubkey — стабильный Ed25519 pubkey, выведенный из recovery-фразы. Цепочка доверия:

text
account_root_pubkey  ← anchor (выведен из recovery phrase)
       │ подписан account_root_sk (первый device, slice 6)

[Device A SignedDeviceCert]
       │ подписан Device A's DeviceSignKey (Stronghold)

[Device B SignedDeviceCert]  parent=hash(Device A's cert)
       │ подписан Device A's DeviceSignKey

[Device C SignedDeviceCert]  parent=hash(Device B's cert)  // или Device A's — любой already-trusted
       │ …

verify_chain проверяет цепочку до account_root_pubkey-anchor; max-depth ограничивает chain-длину (по умолчанию 4).

Выбор «кто кого подписывает»:

Cross-sign flow симметричен по аккаунту, асимметричен по роли:

  • Host (already-paired device) подписывает joiner-cert своим DeviceSignKey. Joiner получает signed cert + host'а cert (для chain-verify до anchor'а).
  • Оба после join'а анкорятся на тот же account_root_pubkey (joiner получает его из issued cert и one-shot записывает в свой identity row).
  • Направление join'а (кто host, кто joiner) выбирается на UX-уровне согласно ADR-5: устройство с меньшим device_count должно быть joiner'ом. Backend не enforce'ит правило (joiner волен пойти в любой аккаунт), но фронт обязан подсказывать.
  • При коллизии (joiner уже имеет другой account_root_pubkey) — set_account_root_pubkey отвергает запись с другим значением. UX должен предложить пользователю явный «discard local account».

Обоснование:

  • Никаких приватных байт на проводе — joiner шлёт только pubkey'и, signing происходит на host'е через Stronghold.
  • Recovery после потери snapshot'аaccount_root_pubkey восстанавливается из recovery-фразы, а device-ключи можно регенерировать; cert-chain extension добавит новый device под тем же anchor'ом.
  • Multi-device без shared private key — каждое устройство имеет свой DeviceSignKey, что упрощает per-device revocation.

ADR-8: Две Space-подсистемы — крипто-спайн членства vs legacy файловый sync; reground вместо collapse

Контекст. В коде сосуществуют два «Space»-стора, и их легко спутать как «два источника правды об одном»:

  • Новый крипто-спайн членства (backend/src/spaces/{op_store,group_key,cert_store,service,runtime}.rs поверх kontinuum_core::node::space_fold): подписанные SignedMemberOp в причинном DAG, device-cert-цепочки, FanOut group-keys (at-rest-зашифрованы). Доказанный авторитет «кто в Space». Ключ — space_id = [u8;32] (хеш creator_root‖kind‖nonce).
  • Legacy SpaceManager (backend/src/spaces/manager.rs): субстрат файловой sync/sharing-подсистемы — таблицы spaces, space_files, received_spaces, space_device_sync, sync_tombstones, sync_conflicts. Это докрипто попытка файлового продукта. Ключ — uuid_v4 (TEXT) + локальный identity_id.

Решение (инвентарь S6, пофилдовая классификация). «Схлопнуть legacy → new как единственный read-model» — неприменимо: это два дизъюнктных домена с непересекающимися ID-пространствами (у legacy-Space нет space_id, у new-Space нет имени/файлов; ID-моста между ними в коде нет). Схлопывать нечего. Классификация полей legacy:

  • (A) факт членства/существования → ничего из legacy сюда чисто не ложится; ближайшее (spaces.identity_id, received_spaces) — это не криптографическое членство new-модели.
  • (B) чистая презентация (name, description, icon, color, sync_mode, флаги, created_at/updated_at) → UI-кэш, кандидат в sidecar по space_id — но привязать нельзя без ID-моста.
  • (C) состояние без крипто-провенанса (received_spaces + space_type=own/published/shared, плюс вся файловая модель space_files/device_sync/tombstones/conflicts) → received_spaces = «Space расшарен со мной» без доказательства. Это недоказуемая версия того, что new-спайн делает доказуемо.

End-state — не collapse и не «параллельно навсегда», а reground: файловая подсистема перезаземляется на спайн членства. Файл доступен, потому что ты — доказанный участник space_id (через fold), а не потому что так сказал received_spaces. Архитектура «spine членства + content/file-плоскость поверх него».

Пере-секвенс роадмапа (зависимость инвертирована). received_spaces = inbound-сторона cross-account членства, которое slice A намеренно НЕ открыл (add/revoke над дырой bundle-канала). Значит reground зависит от bundle-канала. Было предположено A → S6 → bundle; инвентарь перевернул: A → bundle-канал → reground. ID-мост / shadow-read equivalence сейчас преждевременны (new-Space'ы существуют только single-account, self-created; сравнивать equivalence не с чем, а для (C) нет new-членства до bundle).

Статус: slice A закрыт (крипто-спайн + доказанная command-поверхность). Следующее — bundle-канал (publication-слайс: cert-chains/box-pubkey'и самоверифицируемы, off-meet; MVP через QR/ручной export↔import, зеркало пейринга). Reground — крупный эпик ПОСЛЕ bundle. Frontend на спайне членства (space-management UI поверх уже открытой поверхности A) независим; файл-шаринг UI остаётся на legacy до reground — это два разных «frontend», не смешивать.

Обновление (2026-06-01): bundle-канал закрыт (3 слайса: носитель, IPC export/import, атомарный revoke+rotate + add/revoke IPC; DoD keystone_interop 3 passed на реальном loopback). Reground разблокирован — см. ADR-9.


ADR-9: Reground — декомпозиция эпика; auth-gate первым; (C) = re-join, не trust-migrate

Контекст. ADR-8 установил: legacy файловая sync/sharing-плоскость (SpaceManager) и крипто-спайн членства (op_store/fold) — дизъюнктны. Reground = перезаземлить file-плоскость на доказанное членство (space_id-fold), заменив провенанс received_spaces. bundle-канал (предусловие) готов.

Находка инвентаря отдающих путей (по коду, не догадка). Сегодня файлы отдаются пиру без проверки членства на отдающей стороне — это не «без провенанса», это без авторизации вообще: кто знает space_id, тот получает данные. Полная карта отдающих путей (все без gate):

ПутьHandlerЧто отдаёт
метаданные Spacebuild_space_payload (sharing/manager.rs)список файлов Space
контент файла (блоб)FileRequested (fs::read(file_path) → chunks)байты файла
directory listingDirectoryListRequestedлистинг каталога
thumbnailThumbnailRequestedпревью
stream-файлStreamFileRequestedпотоковые чанки

received_spaces.accepted гейтит только приём/показ у получателя, НЕ отдачу. Загейтить один путь (метаданные) и оставить блобы открытыми = #3045 («проверка не во всех путях») на живой дыре конфиденциальности.

Решения.

  1. files ≠ membership. File-плоскость остаётся своим транспортом (durable-tombstone state-sync, 30-дн TTL — переиспользуется как есть, #3062-Bug-2 не воспроизводится); мутации членства в op-log НЕ уходят. Доступ к файлам гейтится fold-членством space_id.

  2. Единый authz-аксессор. requester_account ∈ fold_membership(space_id) — один проверочный путь, через который проходят ВСЕ пять отдающих путей (метаданные И контент И листинг И thumbnail И stream), не россыпь чеков.

  3. (C) = re-join, не trust-migrate (enforced-инвариант). Существующие received_spaces («X расшарил со мной», без крипто-провенанса) НЕ промотятся в доказанное членство. Trust-migrate отмыл бы недоказуемое в доказанную модель, наполнив корень доверия неверифицированными членами (#3059 на уровне soundness) — и у такого Add нет легитимного подписанта (устройство владельца авто-форжило бы Add'ы для непроверенных членств). Re-join: владелец сознательно пере-создаёт legacy-шару как new-Space и add_member'ит каждого через верифицированный bundle. Координированность («владелец re-add») — это и есть провенанс, не издержка. Цена ограничена/разова; у trust-migrate — постоянная невидимая популяция недоказуемых членств. Инвариант: ни один кодопуть не промотит received_spaces → fold-членство без свежего owner-signed, bundle-verified add_member.

  4. Authz-модель на переходный период. new-Space — gate-from-birth (членство обязательно). legacy-uuid-Space — grandfather + интерим-чек на отдаче: проверять received_spaces и при отдаче (не только приёме) → сужает «любой с uuid» до «аккаунты из моего share-листа». Это defense-in-depth, НЕ провенанс; legacy не маркетится как secure, re-establish активно драйвится.

  5. AE как safety-net композится с re-join. Членство сходится через существующий Merkle-AE на membership-слое → член, бывший офлайн в момент owner-re-add, узнаёт членство через AE и потом тянет файлы (загейтенно). Re-establish не требует, чтобы все были онлайн в момент re-add.

Декомпозиция эпика (зависимости чисты, никакого big-bang; data-touching слайсы под equivalence-DoD + обратимость + «ничего не выброшено до приземления нового дома»):

СлайсЧтоDoD (адверсариальный)
0 — ADR-9 (этот док)зафиксировать инвариантыдок-ревью
1 — auth-gatewiring → SpaceServiceCell + bridge-resolve (legacy-uuid ↔ core space_id) + единый authz-аксессор на ВСЕ 5 путей + интерим legacy-чекне-член REFUSED / член served на обоих путях (метаданные+блоб); legacy-не-шара refused; grandfather ungated-legacy не изменился; обратимо
2 — new-Space ↔ file-plane keyingфайлы прицепляются к new-Space (ключ = core space_id), синк по durable-tombstone транспорту, гейтится слайсом-1файл синкается доказанному члену, не не-члену (сквозной gate на loopback); tombstone-delete доезжает; shadow-read equivalence
3 — re-establishment ((C))владелец пере-создаёт legacy-шару как new-Space + re-add через bundle add_memberпере-установленная шара отдаёт только доказанным членам; адверсариально: НЕТ кодопути received_spaces→fold без owner-signed add
4 — retirement legacyубрать/закарантинить ungated legacy-отдачу, когда re-establish — единственный путьungated-путей не осталось; equivalence зелёная; rollback доказан
frontend (параллельно)space-management UI поверх slice-A+bundle; безопасный шаринг — после 2–3

Развилки, которые слайсы должны закрыть явно: кодировка core space_id в file-плоскости (string-encode vs dual-key) — решается в слайсе 1; точный механизм проводки sharing → SpaceServiceCell (sharing сейчас держит Arc<Mutex<SpaceManager>>, не ячейку).

Обновление (2026-06-01): слайс 1 закрыт (auth-gate, fail-closed); слайс 2 расщеплён 2b-i → 2b-ii. Слайс 2 бандлил data-model-изменение (keying) и security-критичную интеграцию (gate-live, потребляющий ResolvedAccount из binding-слайса 2a). Разнесены под фокусный DoD каждый, как 2a отслоился от 2:

  • 2b-i — keying (закрыт). Развилка «string-encode vs dual-key» решена в пользу string-encode каноническим core space_id, без uuid↔core моста. Реализация: sharing/space_key.rscore_space_key(&[u8;32]) → 64-hex (lowercase-канонический), parse_core_space_key (только канонический lowercase — 1:1 string↔bytes, no second representation: апперкейс-hex того же id отвергается; это и есть защита от #3059-моста), classify(&str) → {Core, Legacy} (тотальный дискриминатор по форме ключа — отдельного флага, способного разойтись, нет). SpaceManager::register_core_space(&[u8;32], …) — единственный вход membership-backed Space в file-плоскость: spaces.id = hex(core), uuid не чеканится, идемпотентно (INSERT OR IGNORE), хранится non-published (slice-1 gate держит fail-closed DENY до 2b-ii). Legacy create_space остаётся на uuid; key-пространства дизъюнктны по форме (36-char hyphenated uuid vs 64-hex без дефисов — пересечения нет). DoD-зелёное: sharing::space_key 4 passed; core_keying_tests 5 passed (файлы/sync-листинг/tombstone Space-сущности keyed по core id; uuid не создаётся; idempotent один ряд; legacy disjoint).

  • 2b-ii — fold-gate-live: decision-слой закрыт (2026-06-02). Живой FileRequested ветвится по classify (2b-i): Legacylegacy_serving_decision (slice-1 published/deny); Corecore_serving_decision. Все 5 адверсариальных свойств — структурны, не тест-ловимы:

    • transplant-guard как конструктор типа. RequesterOnConnection (account, привязанный к соединению) конструируется ТОЛЬКО через ResolvedAccount::bind_to_request(req_conn) (Someconn_peer_id == req_conn). Core-authz потребляет RequesterOnConnection, не сырую пару (resolved, peer) — достичь membership-проверки с биндингом чужого соединения невыразимо по типу. (2a: no-forge; 2b-ii: no-transplant — use-time twin.)
    • оба плеча обязательны, от факта. files_of(core_key).contains(file_hash) (F реально в цитируемом space) И membership.is_member(requester) (живой fold). owning-space НЕ выводится из F (content-addressed дедуп → F multi-homed); claim — только routing, не authz-вход. Serve — единственный success-терминал (chokepoint slice-1 сохранён над спайном членства).
    • fail-closed на каждом None/false: нет биндинга / биндинг чужого conn / F не в цитируемом space / membership=None (unknown/unfolded) / ∉ membership. «membership-unknown ⇒ Serve» невыразимо.

    DoD-зелёное (прочитано из вывода): sharing::authz 15 passed; sharing::binding 12 passed (+transplant-guard); sharing::space_key 4; core_keying_tests 5 — все 0 failed. Покрыто: transplant (conn P→P′ Denied), cross-space оба плеча (member-of-S без F → Denied; F-present без membership → Denied), revoke-reflection (member→post-revoke fold → Denied), fail-closed (no-binding / unfolded), multi-device pos (2-е устройство → тот же AccountId → Serve) + neg (чужой account_root → ∉ → Denied).

    Живой Core-путь fail-closed: classify менялся на SpaceKeyKind::Core([u8;32]) (parse-once). В FileRequested Core-арка вызывается с resolved=None (биндинг-продьюсер ещё не подключён) ⇒ Core-Space байты не отдаются. Это ровно то, что обещали module-доки slice-1 («fold-membership branch goes live, still fail-closed: no resolution ⇒ DENY»).

  • 2b-iii — binding-handshake-продьюсер (закрыт, оба слоя — 2026-06-02). Producer ResolvedAccount поверх живого libp2p: challenge-on-PeerDiscovered (issue noise-аутентифицированного PeerId в ChallengeRegistry; симметрично — обе стороны discover'ят друг друга через Announce request+response), приём BindingProofresolve_proof → per-connection ResolvedAccount-store (PeerBindings), requester строит proof из живого SpaceServiceCell (cert-chain) + Stronghold-подписи (build_proof), + per-request проекция живого fold из cell в serving-loop. Cleanup-evict на disconnect/expiry (reconnect ⇒ ре-proof). core_serving_decision не менялся — проводка только питает его resolved из store и membership из cell.

    Структурные гарантии: (1) store ключуется по noise-PeerId → cross-connection leak невыразим (разные аутентифицированные сущности — разные слоты; PeerId неподделываем); transplant-guard (2b-ii) композится belt-and-suspenders. (2) challenge на server-observed PeerId, atomic single-use (2a), disconnect-race fail-safe (evict → lookup miss → Denied). (3) два структурных fail-closed None: нет биндинга для соединения, или locked-vault/unknown-space membership → Denied; default-allow нет нигде. (4) chokepoint пере-проверен после проводки: FileChunk-байты эмитятся ТОЛЬКО из ServeOutcome::Serve (два места конструкции FileChunk — inbound-dispatch и serve-emit под Serve-аркой; новые сообщения контента не несут; второго byte-пути нет).

    DoD — два слоя, прочитано из вывода. Детерминированный (проводка): sharing::binding 17 passed (PeerBindings: PeerId-keyed leak-guard, single-use под disconnect, relay оставляет attacker-слот пустым, cleanup evicts); authz/space_key/core_keying зелёные. Интеграция (explicit-dial, живой libp2p, backend/tests/sharing_binding_wire.rs): 3 passed — член обслужен end-to-end (connect→challenge→proof→resolve→store→FileRequest→Serve→байты по проводу); доказанный не-член denied (membership-плечо); без-хэндшейка denied (binding-плечо). Не-член/без-хэндшейка получают НОЛЬ байт = chokepoint по проводу. 2b закрыт целиком (2b-i keying + 2b-ii gate + 2b-iii producer).

Обновление (2026-06-02): слайс 3 схлопнулся, слайс 4 частично закрыт, раскрыт новый prerequisite.

  • Слайс 3 (re-establishment) схлопнулся в инвариант — по коду нет legacy-шар-данных для re-establish (received_spaces наполняется только в рантайме, pre-release пусто), и инвариант (C) уже держится структурно: grep по legacy+sharing-плоскостям на add_member/author_*/SpaceOpStore/current_membership → пусто; register_core_space имеет ноль прод-вызовов. Плоскости дизъюнктны, моста нет. (C)-guard фиксируется в слайсе 4.

  • Слайс 4 (retirement) — закрыто: serving-collapse + снос мёртвых sender'ов. (4.1) serving-плоскость схлопнута в одну: FileRequestedCore → core_serving_decision; Legacy → Denied; authorize_serve+legacy_serving_decision удалены; legacy-uuid serving (включая Published-broadcast Allow) ретайрнут. (4.2/4.2b) сняты truly-dead legacy-sender'ы — cross-account Share-кластер (msg+event+dispatch+command+receive-handler+IPC) и public PublishSpace-команда — каждый per-item caller-traced по телу (ноль KEEP-ссылок); пойман second-order dead (SpaceType::Shared стал write-dead → снят вместе с read-арками; orphaned SHARING_HKDF_INFO_KEY_WRAP снят). DoD прочитан из вывода: chokepoint обе семьи выжили (FileChunk только из ServeOutcome::Serve; authorize_path_serve wholesale-DENY), no-dangling, no-newly-dead, sharing:: 42 passed / core_keying 5 passed / 0 failed. path-keyed = A (denied-каркас остаётся; ноль ссылок на Share И received_spaces → не dangling; rekey re-grounds его как 2b сделал с FileRequested).

Итог дуги (2026-06-02): reground по существу завершён (A → bundle → 2b → slice 4). Serving-плоскость одна (Core-or-Deny); legacy cross-account + public-broadcast сняты per-item-traced; gate live + binding (challenge-response, channel-bound) + (C)-guard; path-keyed=A.

Оставшаяся forward-работа по reground — re-key (own-multi-device space sync → core-model), retirement received_spaces/Publish/SpaceType::Published, fold-in seam-closure и frontend-регенерация bindings — вынесена в GitLab-issue #48.


Открытые вопросы и forward-work по архитектуре (индексы БД при масштабе, типизация ошибок, operation-lock, sender-auth / sign-then-encrypt, production-SLO, доступ к файлам без доказанного членства до завершения reground) вынесены в GitLab-issue #48.

Платформы

Android

Android-версия активно разрабатывается и тестируется. Сборка через Tauri 2.x Android target:

bash
nx run backend:build:android

mDNS discovery работает через Android NSD (Network Service Discovery). Необходимые permissions:

  • android.permission.INTERNET
  • android.permission.ACCESS_NETWORK_STATE
  • android.permission.ACCESS_WIFI_STATE
  • android.permission.CHANGE_WIFI_MULTICAST_STATE (для mDNS)

iOS / macOS

Версии для iOS и macOS пока не реализованы. Tauri 2.x поддерживает iOS через tauri ios toolchain; план по этим платформам — в GitLab-issue #48.