Архитектурные решения
Этот документ фиксирует ключевые архитектурные решения (ADR) и известные компромиссы проекта как состояние «как построено». Цель — прозрачность для разработчиков и AI-ассистентов.
Архитектурные решения
ADR-1: Concurrency — Arc<Mutex<T>> без формальной стратегии порядка блокировок
Контекст. Все менеджеры обёрнуты в Arc<Mutex<T>>. SharingManager держит ссылки на 5 других менеджеров, PairingManager — на 4.
Риск. Теоретически возможен deadlock, если два потока захватывают мьютексы в разном порядке.
Почему сейчас это работает:
- Сервисный слой — единственный вызывающий. Сервисные функции (
*_svc) захватывают мьютексы последовательно:lock()→ операция →drop(guard)→ следующийlock(). Два мьютекса одновременно не удерживаются. - Фоновые задачи изолированы.
SharingManagerиPairingManagerотправляют команды черезmpsc-каналы. Фоновыйtokio::spawnработает с собственным экземпляром менеджеров и не конкурирует с сервисным слоем. - Отсутствие вложенных блокировок. Менеджеры не вызывают друг друга напрямую — 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— полный снимок для mergeSyncUpdate— инкрементальные обновления- Tombstones — маркеры удаления (TTL 30 дней)
Это НЕ полноценный CRDT, потому что:
- Нет vector clock'ов — невозможно определить causal ordering
- Merge = "добавить всё, чего нет" (OR-Set семантика для добавлений)
- Удаления — через tombstones (2P-Set семантика)
- Конфликты записи в файлы — отдельная подсистема (
sync_conflicts)
Почему этого достаточно:
- Устройства одного владельца. Конфликты маловероятны — обычно один человек работает на одном устройстве.
- Spaces/devices — append-mostly. Создание пространств и добавление устройств — идемпотентные операции.
- Файловые конфликты — отдельно. Для файлов используется hash-comparison + explicit conflict resolution.
Известное ограничение: Если устройство offline > 30 дней (tombstone TTL), оно может "воскресить" удалённые пространства при синхронизации. Это acceptable trade-off: длительный offline — edge case, а tombstones нельзя хранить вечно.
ADR-4: Гибридное хранилище — VaultManager + StrongholdManager
Контекст. Проект использует два менеджера для криптографических операций:
| VaultManager | StrongholdManager | |
|---|---|---|
| Хранение | SQLite (encrypted secrets) | IOTA Stronghold snapshot |
| Защита памяти | Zeroizing (зануление при drop) | Guard pages, mlock, mprotect, non-contiguous memory |
| Модель доступа | Секреты расшифровываются в RAM | Vault: write-only, операции через Procedures |
| KDF | Argon2id (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:
Используемые сейчас возможности Stronghold:
GenerateKey(Ed25519 + X25519) — DeviceSignKey / DeviceBoxKey, генерация in-place.PublicKey— извлечение pubkey'я уже-сгенерированного secret'а.Ed25519Sign/X25519DiffieHellman/Hkdf/AeadDecrypt— chain для unwrap'аWrappedSpaceKey.- Single client с разными
SecretTypelocation'ами (фактически namespaces) —b"device_sign_key",b"device_box_key",b"libp2p_private_key", …
Неиспользованные возможности Stronghold:
BIP39Generate/BIP39Recover— recovery-фраза сейчас делается на VaultManager-стороне (BIP39 черезbip39crate).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'ам (см. ниже).
Алгоритм:
- Initiator отправляет
device_countвместе с запросом - Confirmer сравнивает со своим
device_count - Устройство с меньшим числом девайсов принимает identity / account_root другого
- При равенстве — 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_pairingcase A/B). Frontend получает уже разрешённый результат. - Cross-sign flow (ADR-7) — joiner всегда наследует
account_root_pubkeyhost'а (кто шлёт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-фразы. Цепочка доверия:
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 | Что отдаёт |
|---|---|---|
| метаданные Space | build_space_payload (sharing/manager.rs) | список файлов Space |
| контент файла (блоб) | FileRequested (fs::read(file_path) → chunks) | байты файла |
| directory listing | DirectoryListRequested | листинг каталога |
| thumbnail | ThumbnailRequested | превью |
| stream-файл | StreamFileRequested | потоковые чанки |
received_spaces.accepted гейтит только приём/показ у получателя, НЕ отдачу. Загейтить один путь (метаданные) и оставить блобы открытыми = #3045 («проверка не во всех путях») на живой дыре конфиденциальности.
Решения.
files ≠ membership. File-плоскость остаётся своим транспортом (durable-tombstone state-sync, 30-дн TTL — переиспользуется как есть, #3062-Bug-2 не воспроизводится); мутации членства в op-log НЕ уходят. Доступ к файлам гейтится fold-членством
space_id.Единый authz-аксессор.
requester_account ∈ fold_membership(space_id)— один проверочный путь, через который проходят ВСЕ пять отдающих путей (метаданные И контент И листинг И thumbnail И stream), не россыпь чеков.(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-verifiedadd_member.Authz-модель на переходный период. new-Space — gate-from-birth (членство обязательно). legacy-uuid-Space — grandfather + интерим-чек на отдаче: проверять
received_spacesи при отдаче (не только приёме) → сужает «любой с uuid» до «аккаунты из моего share-листа». Это defense-in-depth, НЕ провенанс; legacy не маркетится как secure, re-establish активно драйвится.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-gate | wiring → 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.rs—core_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). Legacycreate_spaceостаётся на uuid; key-пространства дизъюнктны по форме (36-char hyphenated uuid vs 64-hex без дефисов — пересечения нет). DoD-зелёное:sharing::space_key4 passed;core_keying_tests5 passed (файлы/sync-листинг/tombstone Space-сущности keyed по core id; uuid не создаётся; idempotent один ряд; legacy disjoint).2b-ii — fold-gate-live: decision-слой закрыт (2026-06-02). Живой
FileRequestedветвится поclassify(2b-i):Legacy→legacy_serving_decision(slice-1 published/deny);Core→core_serving_decision. Все 5 адверсариальных свойств — структурны, не тест-ловимы:- transplant-guard как конструктор типа.
RequesterOnConnection(account, привязанный к соединению) конструируется ТОЛЬКО черезResolvedAccount::bind_to_request(req_conn)(Some⇔conn_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::authz15 passed;sharing::binding12 passed (+transplant-guard);sharing::space_key4;core_keying_tests5 — все 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). ВFileRequestedCore-арка вызывается сresolved=None(биндинг-продьюсер ещё не подключён) ⇒ Core-Space байты не отдаются. Это ровно то, что обещали module-доки slice-1 («fold-membership branch goes live, still fail-closed: no resolution ⇒ DENY»).- transplant-guard как конструктор типа.
2b-iii — binding-handshake-продьюсер (закрыт, оба слоя — 2026-06-02). Producer
ResolvedAccountповерх живого libp2p: challenge-on-PeerDiscovered(issue noise-аутентифицированного PeerId вChallengeRegistry; симметрично — обе стороны discover'ят друг друга через Announce request+response), приёмBindingProof→resolve_proof→ per-connectionResolvedAccount-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::binding17 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-плоскость схлопнута в одну:
FileRequested→Core → 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-accountShare-кластер (msg+event+dispatch+command+receive-handler+IPC) и publicPublishSpace-команда — каждый per-item caller-traced по телу (ноль KEEP-ссылок); пойман second-order dead (SpaceType::Sharedстал write-dead → снят вместе с read-арками; orphanedSHARING_HKDF_INFO_KEY_WRAPснят). DoD прочитан из вывода: chokepoint обе семьи выжили (FileChunk только изServeOutcome::Serve;authorize_path_servewholesale-DENY), no-dangling, no-newly-dead,sharing::42 passed /core_keying5 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:
nx run backend:build:androidmDNS discovery работает через Android NSD (Network Service Discovery). Необходимые permissions:
android.permission.INTERNETandroid.permission.ACCESS_NETWORK_STATEandroid.permission.ACCESS_WIFI_STATEandroid.permission.CHANGE_WIFI_MULTICAST_STATE(для mDNS)
iOS / macOS
Версии для iOS и macOS пока не реализованы. Tauri 2.x поддерживает iOS через tauri ios toolchain; план по этим платформам — в GitLab-issue #48.