Skopka.Chat — переиспользуемый транспорт-независимый движок личного и малого группового чата для .NET 10. Сервер регистрирует публичные данные устройств, хранит и доставляет зашифрованные конверты, но не получает закрытые ключи и не имеет API расшифровки.
Security status: это ограниченный E2EE MVP, а не реализация Signal и не прошедший аудит production-протокол. В v1 нет Double Ratchet, forward secrecy относительно компрометации долгосрочного ключа получателя и post-compromise security. Перед использованием прочитайте threat model, ADR по криптографии и ограничения MVP.
Версия 0.19.0 ещё не опубликована. Текущая ветка разработки добавляет первый серверный event-срез: envelope и
PostgreSQL Outbox фиксируются одной транзакцией, а отдельный optional
Skopka.Chat.Server.Kafka публикует metadata-only событие с at-least-once
семантикой. Синхронные HTTP-команды, polling/history и E2EE-форматы не меняются;
Kafka не становится источником истины. См. руководство по server events
и ADR 0022.
Версия 0.18.0 добавляет малые групповые диалоги до 64 участников, роли
владельца/администратора/участника, атомарную revision-модель PostgreSQL и
recipient-per-device E2EE fan-out. Новые участники не получают старую историю
автоматически, а удалённые сохраняют уже доставленные данные, но перестают получать
новые конверты. Структурированные @user и @all находятся внутри нового
content-v4; обычный текст без упоминаний остаётся byte-identical content-v1.
@all считается действительным только от владельца или администратора на стороне
получателя. Это bounded MVP без MLS, ratchet и криптографически защищённого журнала
состава; подробности — ADR 0021.
Версия 0.17.0 добавляет browser-bound открытие локального vault без ежедневной
фразы. Новый браузер создаёт неэкспортируемый WebCrypto-ключ в IndexedDB, а
существующий vault 0.16 можно один раз открыть прежней фразой и привязать без
перешифрования записей или замены DeviceId. Ключ не отправляется на сервер.
Для явно одноразовых установок DiscardLegacyAsync позволяет хосту после
информированного подтверждения удалить только legacy-scope и создать новый vault.
Защита рассчитана на кражу экспортированных ciphertext, но не на вредоносный код
того же origin или доступ к разблокированному профилю браузера.
Версия 0.16.0 добавляет opt-in E2EE backup текстовой истории,
восстановление без старого устройства, отдельный случайный recovery key, Browser/MAUI
адаптеры и account-scoped PostgreSQL/HTTP. Device identity и outbox не переносятся;
импорт явно помечен как recovery-key-authenticated, а не повторно проверенная подпись
отправителя. Подключение и ограничения — резервные копии.
Форматы envelope/content/binding не изменены. Для backup требуется явное подключение
новых серверных маршрутов и отдельная PostgreSQL migration; обычный чат продолжает
работать без них. Согласованный набор по-прежнему состоит из 23 пакетов.
Версия 0.15.0 добавляет Skopka.Chat.Client.Browser: standalone Blazor WebAssembly, общий с native клиентский движок, локальная libsodium.js, зашифрованные IndexedDB identity/history/outbox и cookie/BFF-интеграция без OAuth-токенов в браузере. Старые NSec-ключи и серверные протоколы сохранены. Проверяются реальные Chromium и Firefox, включая взаимное E2EE с .NET, перезагрузки и конкурентные вкладки. Описание, ограничения и запускаемый пример — в руководстве браузерного клиента.
В этот же релиз входят Skopka.Chat.Bots, .Bots.Sqlite и .Bots.AspNetCore: текстовый bot-runtime, долговечная очередь и приватный HTTP-шлюз у владельца бота. Основной сервер не расшифровывает сообщения. Подтверждение оператора, экран согласия и серверные правила доступа подключает приложение-хост; без них включать ботов нельзя. См. интеграцию ботов. Согласованный набор — 23 пакета (20 core/bot + 1 browser + 2 MAUI).
-
Skopka.Chat.Protocol— идентификаторы, лимиты, публичные контракты и каноническое бинарное представление v1; без ASP.NET Core, EF Core и криптографии клиента. -
Skopka.Chat.Attachments— transport-neutral контракт immutable ciphertext storage, авторизация upload/download/delete и общие лимиты; зависит только от Protocol. -
Skopka.Chat.Attachments.PostgreSql— отдельныйAttachmentDbContext, migration и ограниченноеbytea-хранилище для небольших зашифрованных файлов. -
Skopka.Chat.Attachments.S3— потоковое S3-compatible хранилище с conditional create, проверкой длины/SHA-256 и без перезаписи объекта. -
Skopka.Chat.Client— общий движок для native/browser: идентичность,IDeviceKeyStore, платформенная граница криптографии (по умолчанию NSec на native), typed content, fan-out, файлы, проекции, fingerprints иIChatTransport. -
Skopka.Chat.Client.Browser— browser-only криптография и защищённое локальное хранение, постоянная identity, durable очередь и same-origin cookie/CSRF адаптеры. Поддерживает browser-bound неэкспортируемый ключ; прежняя отдельная фраза нужна только один раз для миграции vault0.16. -
Skopka.Chat.Client.Storage— durable journal contracts, восстановление проекций иChatSyncCoordinatorс порядком verify/decrypt → store → apply → acknowledge. -
Skopka.Chat.Client.Storage.Sqlite— локальный SQLite-журнал проверенных typed events с атомарной дедупликациейMessageId; хранит plaintext и требует host-защиты файла БД. -
Skopka.Chat.Bots— клиентский runtime текстовых ботов с проверкой host-owned согласия и раскрытия оператора. -
Skopka.Chat.Bots.Sqlite— долговечный inbox бота, acknowledgements и идемпотентность исходящих запросов; защита локальной БД остаётся у владельца. -
Skopka.Chat.Bots.AspNetCore— приватный HTTP-шлюз у владельца бота и защищённый Data Protection адаптер identity; не часть основного chat server. -
Skopka.Chat.Client.Maui— адаптеры MAUISecureStorage, lifecycle/session coordination и ограниченная работа с временными plaintext-файлами; зависит от Client/Client.Storage/Media, но не от Server. -
Skopka.Chat.Media— client-side режимыAuto/Media/File, заменяемая подготовка фото/видео и orchestration prepare → encrypt → upload; без server/persistence/UI framework. -
Skopka.Chat.Media.FFmpeg— необязательное локальное JPEG/H.264/AAC преобразование через host-supplied FFmpeg; binary не входит в NuGet-пакет. -
Skopka.Chat.UI.Core— framework-independentChatViewModel, composer/reply/reaction/forward/edit commands и host-ownedIChatContentSender; без зависимости от Blazor, transport или server. -
Skopka.Chat.UI.Blazor— доступные Blazor-компоненты с CSS variables, локализуемыми строками и заменяемыми message/attachment/composer templates. -
Skopka.Chat.UI.Maui— виртуализированный нативныйCollectionViewс compiled bindings, стабильным diff, темами, templates и host callbacks для файлов/пересылки/paging. -
Skopka.Chat.Transport.Http— общие HTTP routes, JSON DTO, protocol mappings, лимиты и строгий source-generatedSystem.Text.Jsonпрофиль; зависит только от Protocol. -
Skopka.Chat.Client.Http— typedHttpClient,IAccessTokenProvider, HTTPS-by-default, bounded responses, потоковая загрузка/расшифровка attachments и ограниченные retries идемпотентных операций; без ссылки на Server. -
Skopka.Chat.Server— личные и малые групповые диалоги, роли/состав, жизненный цикл устройств, идемпотентный приём, очередь доставки, acknowledgements и repository-интерфейсы; без ссылки на Client. -
Skopka.Chat.Server.Kafka— необязательный Kafka publisher и hosted PostgreSQL-Outbox dispatcher; зависит от transport-neutral контрактов Server, не заменяет историю и не входит в доменное ядро. -
Skopka.Chat.Server.NSec— optional Ed25519 verifier для device-binding proof через существующий NSec; без private keys/decryption API и без зависимости Server → Client. -
Skopka.Chat.Server.AspNetCore— необязательные Minimal API endpoints для envelopes и attachment ciphertext с обязательной авторизацией и строгой привязкой user/device claims; без выбора формата токена или identity provider. -
Skopka.Chat.Persistence.PostgreSql— EF Core 10/Npgsql, PostgreSQL migration, ограниченияbytea, внешние ключи, индексы доставки и TTL cleanup.
Постоянная device identity и opt-in enrollment/rebind из 0.14.0 сохраняются: logout/re-login не меняет DeviceId, ключи и пути history/outbox. В 0.18.0 outer protocol-v1 и binding-v1 не менялись; content-v4 используется только текстом со структурированными упоминаниями. Ни binding, ни локальное шифрование не заменяют Auth, не восстанавливают потерянные ключи и не добавляют ratchet/forward secrecy. См. инструкцию подключения и совместимость.
flowchart LR
Maui[Client.Maui: protected metadata] --> Client[Client: persistent identity + proof]
Http[Client.Http: authenticated bootstrap] --> Client
Http --> DTO[Transport.Http]
Api[Server.AspNetCore: account/device policies] --> DTO
Api --> Server[Server: binding orchestration]
Pg[Persistence.PostgreSql: atomic consume/enroll/bind] --> Server
Verify[Server.NSec: public-key verification] --> Server
Kafka[Server.Kafka: optional event publisher] --> Server
Server --> Protocol[Protocol: canonical binding-v1]
Client --> Protocol
DTO --> Protocol
- Индекс документации
- Зашифрованные резервные копии и восстановление истории
- Браузерный клиент, encrypted vault и BFF
- Боты у владельца и граница доверия
- Руководство по адаптируемому UI
- Руководство по encrypted attachments
- Подготовка фото и видео
- Локальная история и синхронизация
- Интеграция .NET MAUI
- Постоянная identity, re-login и интеграция внешнего Auth
- Руководство разработчика
- Руководство по выпуску
- Инструкции для coding-агентов
- Threat model и security self-review
Реальное приложение должно реализовать IDeviceKeyStore поверх защищённого хранилища платформы. InMemoryDeviceKeyStore предназначен только для тестов и sample.
Низкоуровневое создание ниже — только первый явный enrollment, не обработчик каждого login. Для повторных входов используйте PersistentDeviceIdentityService.LoadAsync и DeviceBindingCoordinator из руководства. Custom key stores должны реализовать atomic TryCreateAsync; создание больше не перезаписывает существующие ключи.
var keyStore = new MyPlatformDeviceKeyStore();
var identityService = new DeviceIdentityService(keyStore);
var alice = await identityService.CreateAsync(userId, DeviceId.New(), DateTimeOffset.UtcNow);
// PublicDevice получателя приходит из аутентифицированного каталога сервера.
var crypto = new ChatCryptoService(keyStore);
var content = new ChatTextContent(ChatContentId.New(), "hello");
EncryptedEnvelope envelope = await crypto.EncryptContentAsync(
content,
conversationId,
MessageId.New(),
alice.DeviceId,
bobPublicDevice,
DateTimeOffset.UtcNow);
await transport.SendAsync(envelope); // transport реализует IChatTransportПолучатель обязан получить текущий PublicDevice отправителя, проверить подпись при расшифровке и сравнить security code по доверенному внешнему каналу при первом контакте или смене ключа:
var receiver = new ChatReceiver(
new ChatCryptoService(keyStore),
new MyTransactionalReceivedMessageStore());
ChatContentReceiveResult result = await receiver.ReceiveContentAsync(
delivery.Envelope,
senderPublicDevice);
var projection = new ChatConversationProjection(delivery.Envelope.ConversationId);
if (result.Delivery is not null)
{
projection.Apply(result.Delivery);
}
string code = SecurityCodes.Between(myPublicDevice, senderPublicDevice);Для постоянной typed history используйте отдельный безопасный pipeline вместо ручного acknowledgement:
var events = new SqliteChatEventStore("Data Source=protected/chat-history.db;Pooling=False");
var projections = new ChatConversationProjectionRegistry();
using var sync = new ChatSyncCoordinator(
transport,
new ChatCryptoService(keyStore),
events,
projections,
myPublicDevice.DeviceId);
await sync.InitializeAsync(cancellationToken); // восстановление до polling
await sync.SynchronizeAsync(100, cancellationToken);
await sync.CommitLocalEchoAsync(successfulSend.Delivery!, cancellationToken);Координатор подтверждает серверную доставку только после проверки, атомарной записи и идемпотентного применения. SQLite хранит канонический расшифрованный content, включая attachment keys; host обязан защищать файл БД, backups и retention. Подробнее: docs/client-storage.md.
ChatContentId идентифицирует одно логическое событие и переиспользуется при шифровании для нескольких устройств. MessageId остаётся уникальным ID recipient-specific конверта:
var original = new ChatTextContent(ChatContentId.New(), "first");
var reply = new ChatTextContent(ChatContentId.New(), "answer", original.ContentId);
var forwarded = original.Forward(ChatContentId.New());
var reaction = new ChatReactionContent(
ChatContentId.New(),
original.ContentId,
"👍",
ChatReactionOperation.Add);
var edit = new ChatEditContent(
ChatContentId.New(),
original.ContentId,
ChatEditField.Text,
"updated text");Пересылка копирует только текст, очищает reply-ссылку и ставит IsForwarded. Она не переносит исходного автора, conversation ID или подпись и поэтому не выдаёт отображаемую атрибуцию за криптографическое доказательство. Реакция и правка — отдельные зашифрованные события; сервер не видит целевой ChatContentId, emoji или новый текст. ChatConversationProjection принимает уже аутентифицированный ReceivedChatContent, сохраняет события, пришедшие раньше цели, и применяет правку только от пользователя — автора исходного сообщения. Последняя правка выбирается по authenticated sender time и ContentId; исходный server ciphertext не переписывается. Подпись вложения редактируется через ChatEditField.AttachmentCaption, а null удаляет только подпись.
Сырые EncryptTextAsync, EncryptAsync, DecryptAsync и ChatReceiver.ReceiveAsync сохранены для приложений 0.1.x–0.7.x; они не пытаются угадать тип содержимого. Новые приложения должны явно использовать EncryptContentAsync, DecryptContentAsync или ReceiveContentAsync.
Файл не помещается в 64-КиБ envelope. Клиент потоково шифрует его независимым случайным ключом по XChaCha20-Poly1305 chunks, загружает ciphertext в выбранное хранилище и только затем отправляет небольшой ChatAttachmentContent каждому устройству. Имя, MIME, caption, plaintext length, ключ и nonce prefix находятся внутри E2EE-манифеста; сервер/S3/PostgreSQL видят только opaque ID, conversation/uploader IDs, ciphertext length/hash и retention timestamps.
await using var input = File.OpenRead(localPath);
await using var encrypted = File.Create(ciphertextPath);
var manifest = await ChatAttachmentCryptoService.EncryptAsync(
input,
input.Length,
encrypted,
AttachmentId.New(),
ChatContentId.New(),
Path.GetFileName(localPath),
"application/octet-stream");
encrypted.Position = 0;
var stored = await api.UploadAttachmentAsync(conversationId, manifest, encrypted);
if (stored is AttachmentStoreResult.Stored or AttachmentStoreResult.Duplicate)
{
await chat.SendAttachmentAsync(manifest);
}Сообщения можно хранить в Skopka.Chat.Persistence.PostgreSql, а files — в Skopka.Chat.Attachments.S3. Для небольших файлов есть независимый Skopka.Chat.Attachments.PostgreSql с default limit 16 МиБ; общий контракт ограничен 5 ГиБ. S3 рекомендуется для больших media. Multipart/resume, range playback, thumbnails и attachment forwarding пока не входят в API. Подробный client/server setup: docs/attachments.md.
Перед шифрованием Skopka.Chat.Media может подготовить локальное фото или видео. Auto сжимает поддерживаемое media, но сохраняет оригинал, если результат не меньше; Media требует преобразование; File полностью обходит процессор и сохраняет исходные байты/name/MIME. Skopka.Chat.Media.FFmpeg выдаёт bounded JPEG или H.264/AAC MP4, удаляет mapped metadata/chapters и использует только защищённую host-owned временную директорию. FFmpeg binary не загружается библиотекой. Полный setup: docs/media.md.
Skopka.Chat.UI.Core хранит только presentation state одного диалога. Для стандартного multi-device пути приложение компонует ChatMultiDeviceSender и MultiDeviceChatContentSender: движок получает авторизованный список peer/sibling devices, сохраняет точный fan-out plan до сети, создаёт отдельный MessageId для каждого устройства и возвращает локальный echo. IChatContentSender остаётся заменяемой host-границей. Входящие данные применяются только после ChatReceiver.ReceiveContentAsync либо через durable ChatSyncCoordinator:
var chat = new ChatViewModel(conversationId, currentUserId, myContentSender);
if (receiveResult.Delivery is not null)
{
chat.Apply(receiveResult.Delivery);
}
chat.SetDraftText("hello");
await chat.TrySendDraftAsync();
chat.BeginEdit(ownMessageContentId);
chat.SetDraftText("corrected text");
await chat.TrySendDraftAsync();Готовый Blazor-компонент можно использовать целиком либо заменить message/composer templates:
@using Skopka.Chat.UI.Blazor
<SkopkaChat ViewModel="Chat"
CssClass="brand-chat"
ForwardRequested="ChooseForwardTarget">
<MessageTemplate Context="item">
<MyMessageBubble Message="item.Message" />
</MessageTemplate>
</SkopkaChat>Цвета, размеры и типографика задаются CSS custom properties с префиксом --skopka-chat-*; строки заменяются через SkopkaChatStrings, quick reactions — через ReactionChoices. Полное руководство и границы безопасности находятся в docs/ui.md.
Для MAUI используйте SkopkaChatView из Skopka.Chat.UI.Maui. Он предоставляет virtualized timeline, стабильное добавление/обновление элементов, paging callback, собственные/чужие bubbles и полностью заменяемые message/attachment/composer/empty templates. Skopka.Chat.Client.Maui добавляет endpoint-адаптеры, но authentication, навигация, push и политика хранения остаются у приложения. Полный composition root находится в samples/Skopka.Chat.Maui.Sample, а требования платформ — в docs/maui.md.
Пример ниже использует прежний claims-based режим. Для opt-in device binding вместо RegisterDeviceAsync сначала выполните Enrollment/Rebind через DeviceBindingCoordinator, как описано в руководстве identity.
Host реализует получение access token и регистрирует отдельный typed client для текущей пары user/device:
builder.Services.AddScoped<IAccessTokenProvider, MyAccessTokenProvider>();
builder.Services.AddSkopkaChatHttpClient(
new Uri("https://chat.example.com/"),
options =>
{
options.AuthenticatedUserId = myPublicDevice.UserId.Value;
options.AuthenticatedDeviceId = myPublicDevice.DeviceId.Value;
});
var api = serviceProvider.GetRequiredService<SkopkaChatHttpClient>();
await api.RegisterDeviceAsync(myPublicDevice);
await api.CreateConversationAsync(peerUserId, conversationId);
PublicDevice peer = await api.GetDeviceAsync(peerDeviceId)
?? throw new InvalidOperationException("Peer device was not found.");
var content = new ChatTextContent(ChatContentId.New(), "hello");
EncryptedEnvelope envelope = await crypto.EncryptContentAsync(
content,
conversationId,
MessageId.New(),
myPublicDevice.DeviceId,
peer,
DateTimeOffset.UtcNow);
await api.SendAsync(envelope);SkopkaChatHttpClient также регистрируется как IChatTransport. Он предназначен для transient/scoped использования, требует HTTPS, отключает redirects в предоставленном DI handler, получает новый токен перед каждой попыткой и не копирует error body или детали JSON parser в исключения. Успешный ответ сначала проходит byte limit, проверку JSON Content-Type, строгий разбор и protocol validation. RequireHttps = false допустим только для доверенного локального TestServer. Подробнее: ADR 0003 и ADR 0006.
Ядро не навязывает HTTP, WebSocket или SignalR. Host проверяет пользователя/access token, затем вызывает движок:
var options = new DbContextOptionsBuilder<ChatDbContext>()
.UseNpgsql(connectionString)
.Options;
await using var db = new ChatDbContext(options);
await db.Database.MigrateAsync();
var store = new PostgreSqlChatStore(db);
var engine = new ChatServerEngine(store, store, store);
await engine.RegisterDeviceAsync(publicDevice);
await engine.CreateConversationAsync(aliceUserId, bobUserId, conversationId, now);
await engine.SubmitAsync(encryptedEnvelope, now);
IReadOnlyList<StoredEnvelope> pending = await engine.ReceiveAsync(recipientDeviceId, 50, now);ChatServerEngine проверяет структуру, лимиты, участников, актуальность key ID, revocation и идемпотентность. Он намеренно не проверяет криптографическую подпись: получатель делает это в Client; транспортный host обязан аутентифицировать право устройства отправлять от своего имени.
Необязательный пакет Skopka.Chat.Server.AspNetCore не выпускает и не валидирует токены. Сначала host настраивает доверенный authentication handler, затем регистрирует движок/repositories и transport:
builder.Services
.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", jwt =>
{
jwt.Authority = identityAuthority;
jwt.Audience = chatAudience;
});
builder.Services.AddAuthorization();
builder.Services.AddDbContext<ChatDbContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddScoped<PostgreSqlChatStore>();
builder.Services.AddScoped<IDeviceRepository>(sp => sp.GetRequiredService<PostgreSqlChatStore>());
builder.Services.AddScoped<IConversationRepository>(sp => sp.GetRequiredService<PostgreSqlChatStore>());
builder.Services.AddScoped<IEnvelopeRepository>(sp => sp.GetRequiredService<PostgreSqlChatStore>());
builder.Services.AddScoped<ChatServerEngine>();
// Optional reliable metadata events; PostgreSQL remains the source of truth.
builder.Services.AddScoped<IChatServerEventOutbox, PostgreSqlChatEventOutbox>();
builder.Services.AddSkopkaChatKafkaServerEvents(options =>
{
options.BootstrapServers = "kafka:9092";
});
builder.Services.AddSkopkaChatAspNetCore(options =>
{
options.UserIdClaimType = ClaimTypes.NameIdentifier;
options.DeviceIdClaimType = "skopka_chat_device_id";
});
// Optional attachment layer: choose this PostgreSQL adapter or S3, not both.
builder.Services.AddDbContext<AttachmentDbContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddScoped<IAttachmentStore, PostgreSqlAttachmentStore>();
builder.Services.AddScoped<IAttachmentAccessAuthorizer, MyConversationAttachmentAuthorizer>();
builder.Services.AddSkopkaChatAttachmentStorage();
app.UseAuthentication();
app.UseAuthorization();
app.MapSkopkaChatApi();По умолчанию обе claims должны встречаться ровно один раз и содержать GUID в формате D. Регистрация получает user ID из principal, отправка сверяет claim устройства с SenderDeviceId, а polling/acknowledgement вообще не принимают recipient device ID от клиента. Общие DTO находятся в Skopka.Chat.Transport.Http, поэтому Client.Http не ссылается на серверную сборку. AddSkopkaChatAspNetCore применяет строгий профиль к общим ASP.NET Core HttpJsonOptions; host с другими Minimal API должен проверить их совместимость с case-sensitive DTO без неизвестных или дублированных полей. Для cookie-authentication host дополнительно обязан настроить CSRF-защиту; для любой схемы обязательны TLS, rate limits и внешний request-size limit. Полное решение зафиксировано в ADR 0002 и ADR 0006.
dotnet restore
dotnet build --no-restore
dotnet test --solution Skopka.Chat.sln --no-build --no-restore
dotnet pack Skopka.Chat.sln --no-restore
dotnet run --project samples/Skopka.Chat.SampleNuGet-пакеты создаются в artifacts/packages.
PostgreSQL integration tests в четырёх проектах могут автоматически поднять изолированную PostgreSQL 18 через Testcontainers. Нужен запущенный Docker:
$env:SKOPKA_CHAT_POSTGRES_TESTCONTAINERS = 'true'
$env:SKOPKA_CHAT_POSTGRES_REQUIRED = 'true'
dotnet test --project tests/Skopka.Chat.Persistence.PostgreSql.Tests
dotnet test --project tests/Skopka.Chat.Attachments.Tests
dotnet test --project tests/Skopka.Chat.Http.IntegrationTests
dotnet test --project tests/Skopka.Chat.Binding.TestsКаждая тестовая сборка получает собственный контейнер и удаляет его после выполнения. Для внешней одноразовой БД задайте SKOPKA_CHAT_POSTGRES; эта переменная имеет приоритет. Без connection string и флага Testcontainers DB-тесты корректно пропускаются; SKOPKA_CHAT_POSTGRES_REQUIRED=true превращает такой пропуск или недоступный Docker в ошибку release-gate.
Workflow .github/workflows/ci.yml запускает core/DB/fuzz gates на Linux, MAUI Android/Windows и package-consumer gate на Windows, iOS/Mac Catalyst + trimming smoke на macOS и только после этого объединяет точный набор из двадцати четырёх пакетов. Используемые GitHub Actions закреплены полными commit SHA; workflow имеет только contents: read.
Каждый CI build также воспроизводит сохранённый JSON/content/binding fuzz corpus, запускает короткую coverage-guided AFL++/SharpFuzz сессию, проверяет real-Kestrel request limits/cancellation и загружает двадцать четыре .nupkg вместе с двадцатью четырьмя .snupkg. Tag v<SemVer> запускает отдельный coordinated release: tag обязан принадлежать main, версия должна совпасть с VersionPrefix, вся версия должна быть свободна на NuGet.org, а после публикации создаётся GitHub Release. Настройка environment и ключа описана в releasing.md.
PostgreSQL delivery остаётся at-least-once: конкурентные poller'ы до acknowledgement могут получить один и тот же конверт. Хранилище держит одну строку на messageId, первый ack атомарно побеждает, а typed client может использовать IChatEventStore/ChatSyncCoordinator для durable store-before-ack; IReceivedMessageStore остаётся низкоуровневой границей ChatReceiver. При одинаковом acceptedAt порядок стабилен по messageId.
Готовый product shell, contact discovery, Avalonia adapter, production-инфраструктура, интеграция со SkopiClub, удаление сообщений, история версий правок, группы, resumable/range media, thumbnails, attachment forwarding, push/background-delivery guarantee и backup/recovery ключей не входят в этот репозиторий. Multi-device sender создаёт отдельный immutable конверт с уникальным MessageId для каждого активного peer/sibling device, переиспользуя один ChatContentId; это fan-out без Double Ratchet и без автоматического доверия новым ключам.