The repository contains the current Go-based CORSA stack:
corsa-desktop: desktop app with an embedded local nodecorsa-node: standalone node process- shared core packages for identity, transport, protocol, encryption, and UI-facing services
- keep protocol and node logic independent from any UI toolkit
- let every desktop instance behave like a peer in the mesh
- support future Android/mobile work without rewriting the core
- keep cryptography, trust, and relay logic inside reusable core packages
- support distinct node roles for full relay nodes and client-only nodes
cmd/corsa-desktop: desktop entrypointcmd/corsa-node: standalone node entrypointinternal/app/desktop: desktop composition, runtime, and Gio windowinternal/app/node: standalone node compositioninternal/core/config: environment-driven config and default pathsinternal/core/identity:ed25519identity,X25519box keys, key binding signaturesinternal/core/directmsg: encrypted and signed direct-message envelopesinternal/core/gazeta: encrypted anonymous notice transportinternal/core/chatlog: append-only file-backed chat message persistence (see chatlog.md). Owned byChatlogGateway(service layer), not bynode.Service.internal/core/node: mesh node, trust store, peer sync, relay (see mesh.md for the full mesh network documentation). Does not own message persistence — delegates to a registeredMessageStorehandler (see chatlog.md).internal/core/service: desktop-facing application service layer (see dm_router.md for the DMRouter service layer).DesktopClientis the composition root; concrete work is delegated toAppInfo(config snapshot),LocalRPCClient(frame dispatch),ChatlogGateway(ownschatlog.Store),MessageStoreAdapter(satisfiesnode.MessageStore),DMCrypto(encrypt/decrypt/send/sync), andNodeProber(probe + read fetches + routing snapshot). New callers should depend on the narrowest sub-service instead of the fullDesktopClientsurface.internal/core/protocol: protocol models (see protocol/ for the full protocol specification)internal/core/netcore: transport core — owns the rawnet.Conn, the writer goroutine and the framing loop; exposes the typednetcore.Networkboundary (SendFrame,SendFrameSync,Enumerate,Close,RemoteAddr, all keyed bydomain.ConnID) thatnode.Servicegoes through. See protocol/network_core.md.internal/core/transport: p2p transport abstractionsinternal/platform/mobile: future mobile bindings- see debug.md for log levels and protocol tracing
internal/core/netcore owns the transport core. Production read-side and
send paths on node.Service go through the netcore.Network interface;
read walks over the registry receive a value-typed connInfo snapshot, not
a *netcore.NetCore pointer. A small lifecycle / handshake carve-out
remains internal to node/conn_registry.go: coreForIDLocked returns the
live *netcore.NetCore handle during handshake-time identity / address /
auth writes, and the registry helpers that create or tear down the
(net.Conn, ConnID) binding (registerInboundConnLocked,
attachOutboundCoreLocked, unregisterConnLocked) necessarily touch the
raw net.Conn. Outside that carve-out, direct net.Conn usage in
internal/core/node is confined to accept entry, pre-registration IP
policy, enableTCPKeepAlive, and the connauth.AuthStore implementation
pinned by an external interface.
The boundary is not aspirational: it is enforced automatically by
make enforce-netcore-boundary (see protocol/network_core.md)
and the same job runs in CI. New net.Conn-first call sites inside
internal/core/node, or new net stdlib imports outside the whitelisted
carve-out files, fail the build.
Node roles:
full: relays mesh traffic, forwards direct messages andGazetanoticesclient: syncs peers and contacts, stores local traffic, but does not forward mesh traffic- current defaults:
corsa-node=>fullcorsa-desktop=>full- future mobile/light client =>
client
Desktop mode:
- load or create identity
- load or create trust store
- start embedded local node
- connect to bootstrap peers
- sync peers and contacts
- render the local chat UI over the local node state
Standalone node mode:
- load identity
- load trust store
- start TCP listener
- sync peers and contacts
- store and relay messages / notices
Current trust and discovery flow:
- fingerprint address is derived from the
ed25519public key boxkeyis signed by the same identity key- peers verify
address + pubkey + boxkey + boxsig - the first valid contact set is pinned locally (TOFU)
- conflicting key rotations are ignored and recorded as trust conflicts
internal/core/ebus provides a lightweight in-process pub/sub event bus that
decouples the node layer from consumers (DMRouter, console UI, SDK).
flowchart LR
subgraph NODE["node.Service"]
PEER_MGMT["Peer management"]
MSG_STORE["Message storage"]
ROUTING["Routing table"]
CM["ConnectionManager"]
end
EBUS["ebus.Bus\n(async, 64-slot inbox)"]
subgraph CONSUMERS["Consumers"]
DMR["DMRouter\n(service layer)"]
CONSOLE["Console UI\n(desktop)"]
SDK_SUB["SDK subscribers"]
end
PEER_MGMT -->|"peer.health.changed\npeer.connected/disconnected"| EBUS
MSG_STORE -->|"message.new\nreceipt.updated"| EBUS
ROUTING -->|"route.table.changed"| EBUS
CM -->|"slot.state.changed"| EBUS
EBUS --> DMR
EBUS --> CONSOLE
EBUS --> SDK_SUB
Diagram — Event bus architecture
Design principles:
- ebus carries only short delta events (state transitions, counters). No bulk data or heavy payloads.
- RPC remains for commands/queries (fetch messages, send messages, routing snapshots). RPC handlers may publish ebus events as side effects.
- Each subscriber gets a dedicated drain goroutine with a 64-slot buffered inbox. Publishers never block.
- Subscriptions are registered before startup so no events are missed.
- The node layer is fully autonomous — ebus is a notification mechanism, not a control channel.
Topics (defined in internal/core/ebus/topics.go): peer.connected,
peer.disconnected, peer.health.changed, peer.pending.changed,
peer.traffic.updated, slot.state.changed, route.table.changed,
message.new, receipt.updated, message.sent, message.send.failed,
message.control, message.delete.completed,
file.sent, file.send.failed, file.received,
contact.added, contact.removed,
identity.added, aggregate.status.changed, version.policy.changed.
The message.delete.completed topic carries a MessageDeleteOutcome
payload (target ID, peer, status, abandoned-flag, attempts) so UI
subscribers can differentiate a successful peer-side deletion from
a denied / immutable / abandoned outcome — all four look identical
at the wire level. See dm-commands.md for the
control-DM contract; the topic is the single observable that drives
the chat-thread row eviction and the file-tab Delete-button state
transition. file.received is published when DMRouter registers a
receiver-side mapping from an inbound file_announce decrypt,
regardless of whether that conversation is currently active —
without this event the Desktop file tab would miss inbound rows
arriving for non-active chats.
ebus subscribers (most importantly the Desktop NodeStatusMonitor) react to
every event by rebuilding a full snapshot and invalidating every window that
observes it. When a single upstream failure — e.g. an i/o timeout cascade
triggered by cm_session_setup_failed — fans out into dozens of peer-state
transitions that land on the same aggregate value, the burst of identical
events manifests as a frozen UI: the drain goroutines are busy, but every
rebuilt snapshot is byte-identical to the previous one.
To keep that class of storm off the wire, the two topics that carry full
content snapshots are gated at the publisher with a no-op filter paired
with a periodic heartbeat resync. The heartbeat is mandatory: ebus
Publish intentionally drops async deliveries when a subscriber inbox is
full (the publisher must never block), so pure content-based dedup would
leave any subscriber whose initial publish happened to be dropped
permanently stale. The heartbeat bounds that staleness to a known window.
version.policy.changed—recomputeVersionPolicyLockedcompares the newVersionPolicySnapshotto the previous one via direct struct equality (==, all fields are comparable). It publishes when the snapshot content changes, on the first recompute (bootstrap), or whenversionPolicyHeartbeatIntervalhas elapsed since the last publish. The heartbeat interval is aligned withversionPolicyRepairInterval, so the existing bootstrap-loop repair tick doubles as the heartbeat driver and no extra scheduling is required.aggregate.status.changed—publishAggregateStatusChangedLockedis the single publish point for the topic. It compares againstlastPublishedAggregateStatus(which tracks what subscribers actually saw, separately fromaggregateStatusbecause init and orphan-eviction paths mutate the latter without publishing) usingAggregateStatusSnapshot.EqualContent, which ignores theComputedAtheartbeat. It publishes when content changes, on the first call, or whenaggregateStatusHeartbeatIntervalhas elapsed. The 2 sbootstrapLoopticker callsrefreshAggregateStatus()which funnels through the same helper — the ticker is what makes the heartbeat observable. MirroringComputedAtintostatus.CheckedAtinNodeStatusMonitorthen keeps the user-visible "last checked" timestamp moving on a quiet but healthy node, rather than freezing when the aggregate counters stop moving.slot.state.changed— emissions are NOT deduplicated at the publisher. ebus lossiness combined with publisher-side memoisation would permanently strand any subscriber whose single publish per slot state was dropped; the subscriber can never recover because no heartbeat is cheap enough to carry the full per-slot state map without coupling the publisher to the subscriber's lifecycle. Slot transitions are distinct events in a bounded state machine, so emitting them unconditionally preserves correctness. Any accidental duplication is absorbed by the downstream delta filter inNodeStatusMonitor.applySlotStateDelta.
The gate is a publisher concern, not a subscriber concern: filtering on the subscriber side would still pay the per-event dispatch cost and the fan-out to every drainer. Gating at the publisher — with a heartbeat to compensate for lossy delivery — keeps ebus semantics honest: an observed event means either a content change or a periodic resync.
Types that live on the read-only snapshot boundary (NodeStatus,
PeerHealth, CaptureSession, DirectMessage, PendingMessage) never
use *time.Time for optional timestamp fields. They use the value type
domain.OptionalTime, declared in internal/core/domain/optional_time.go.
Rationale:
- Pointer snapshots are not deep copies. Copying a struct that
contains
*time.Timealiases the pointee. A UI goroutine that reads the snapshot and a background goroutine that mutates the source see the same timestamp through shared memory, which silently breaks the snapshot contract. OptionalTimeis a value.struct { t time.Time; valid bool }copies by value. A snapshot is a true deep copy — the UI cannot observe mutations from the write path.- Optionality is visible from the type.
optional.Timezero value means "no value", distinct fromtime.Time{}("epoch"). The project rule "absence of a value must be visible from the type, not guessed from a zero value" is enforced structurally.
Ebus payloads (e.g. ebus.PeerHealthDelta.LastConnectedAt,
ebus.CaptureSessionStarted.StartedAt) keep *time.Time because the
nil case has a distinct semantic on a delta: "this delta does not
update this field". That meaning is lost if the field is a value type
with a zero-is-missing convention. Deltas cross the snapshot boundary
only through NodeStatusMonitor.applyX(...), which converts incoming
pointers to OptionalTime via domain.TimeFromPtr(...) at the moment
of application. From that point onwards the state lives as values.
API surface on domain.OptionalTime:
TimeOf(t time.Time) OptionalTime— constructor from a concrete timeTimeFromPtr(p *time.Time) OptionalTime— copies the pointee (returns an invalid value whenp == nil)TimeFromNonZero(t time.Time) OptionalTime— returns invalid whent.IsZero(), useful for wire-leveltime.Time{}inputsValid() bool—trueiff the value is setTime() time.Time— returns the underlying time (zero value when invalid)Ptr() *time.Time— allocates a fresh pointee on every call (safe to hand out to a ebus delta without aliasing state)Equal,Before,After,Sub— value-safe comparisons
- surface trust conflicts in the desktop UI
- add signatures to
Gazetanotices - move from line protocol to structured frames
add persistent storage for message history— done, see chatlog.md- add mobile/light-client bindings over the same core
Репозиторий сейчас содержит актуальный Go-стек CORSA:
corsa-desktop: desktop-приложение со встроенной локальной нодойcorsa-node: отдельный процесс ноды- общие core-пакеты для identity, транспорта, протокола, шифрования и UI-сервисов
- держать протокол и логику ноды независимыми от конкретного UI toolkit
- сделать так, чтобы каждый desktop-инстанс был полноценным peer в mesh
- оставить возможность для будущего Android/mobile клиента без переписывания core
- держать криптографию, trust и relay-логику в переиспользуемых пакетах
- поддерживать разные роли узла: полный relay-узел и client-only узел
cmd/corsa-desktop: точка входа desktop-приложенияcmd/corsa-node: точка входа standalone-нодыinternal/app/desktop: сборка desktop-приложения, runtime и Gio-окноinternal/app/node: сборка standalone-нодыinternal/core/config: конфиг из env и дефолтные путиinternal/core/identity: identity наed25519,X25519box keys, подписи привязки ключейinternal/core/directmsg: зашифрованные и подписанные direct-message envelopesinternal/core/gazeta: зашифрованный анонимный transport для noticesinternal/core/chatlog: append-only хранение истории сообщений на диске (см. chatlog.md). ВладеетChatlogGateway(сервисный слой), а неnode.Service.internal/core/node: mesh-нода, trust store, peer sync, relay (см. mesh.md для полной документации mesh-сети). Не владеет хранением сообщений — делегирует зарегистрированному обработчикуMessageStore(см. chatlog.md).internal/core/service: сервисный слой для desktop-клиента (см. dm_router.md для сервисного слоя DMRouter).DesktopClient— composition root; реальная работа делегируется суб-сервисам:AppInfo(immutable snapshot конфигурации),LocalRPCClient(диспатч фреймов),ChatlogGateway(владеетchatlog.Store),MessageStoreAdapter(реализуетnode.MessageStore),DMCrypto(шифрование/дешифрование/отправка/синхронизация DM) иNodeProber(probe + read fetch + routing snapshot). Новые потребители должны зависеть от узкого суб-сервиса, а не от широкой поверхностиDesktopClient.internal/core/protocol: модели протокола (см. protocol/ для полной спецификации протокола)internal/core/netcore: сетевое ядро — владеет rawnet.Conn, writer-горутиной и циклом фреймирования; предоставляет типизированную границуnetcore.Network(SendFrame,SendFrameSync,Enumerate,Close,RemoteAddr, все ключеныdomain.ConnID), через которую ходитnode.Service. См. protocol/network_core.md.internal/core/transport: p2p-абстракции транспортаinternal/platform/mobile: будущие mobile bindings- см. debug.md для уровней логирования и трассировки протокола
internal/core/netcore владеет transport core. Production read-side и
send-пути node.Service идут через интерфейс netcore.Network;
read-обходы реестра получают value-типизированный снимок connInfo, а не
указатель *netcore.NetCore. Небольшой lifecycle / handshake carve-out
остаётся внутри node/conn_registry.go: coreForIDLocked возвращает
живой handle *netcore.NetCore на время handshake-time записей
identity / address / auth, а registry-хелперы, создающие или разрушающие
биндинг (net.Conn, ConnID) (registerInboundConnLocked,
attachOutboundCoreLocked, unregisterConnLocked), неизбежно трогают
raw net.Conn. За пределами этого carve-out'а прямое использование
net.Conn в internal/core/node ограничено accept entry,
pre-registration IP policy, enableTCPKeepAlive и реализацией
connauth.AuthStore, сигнатура которой диктуется внешним интерфейсом.
Граница не декларативная: она удерживается автоматически через
make enforce-netcore-boundary (см. protocol/network_core.md),
и тот же job крутится в CI. Новые net.Conn-first call-sites внутри
internal/core/node или новые импорты net из stdlib вне whitelist'а
carve-out файлов — это failed build.
Роли узла:
full: ретранслирует mesh-трафик, direct messages иGazetanoticesclient: синкает peers и contacts, хранит локальный трафик, но не форвардит mesh-трафик- текущие значения по умолчанию:
corsa-node=>fullcorsa-desktop=>full- будущий mobile/light client =>
client
В desktop-режиме:
- загружается или создается identity
- загружается или создается trust store
- запускается встроенная локальная нода
- нода подключается к bootstrap peers
- нода синкает peers и contacts
- UI показывает чат поверх состояния локальной ноды
В режиме standalone-ноды:
- загружается identity
- загружается trust store
- поднимается TCP listener
- нода синкает peers и contacts
- нода хранит и ретранслирует сообщения / notices
Текущая схема доверия и discovery:
- fingerprint-адрес получается из
ed25519public key boxkeyподписывается тем же identity key- peer проверяет связку
address + pubkey + boxkey + boxsig - первый валидный набор ключей pin-ится локально по модели TOFU
- конфликтующие замены ключей игнорируются и записываются как trust conflicts
internal/core/ebus предоставляет лёгкую in-process pub/sub шину событий,
отвязывающую слой ноды от потребителей (DMRouter, console UI, SDK).
flowchart LR
subgraph NODE["node.Service"]
PEER_MGMT["Управление пирами"]
MSG_STORE["Хранение сообщений"]
ROUTING["Таблица маршрутизации"]
CM["ConnectionManager"]
end
EBUS["ebus.Bus\n(async, 64-слотовый inbox)"]
subgraph CONSUMERS["Потребители"]
DMR["DMRouter\n(сервисный слой)"]
CONSOLE["Console UI\n(desktop)"]
SDK_SUB["SDK подписчики"]
end
PEER_MGMT -->|"peer.health.changed\npeer.connected/disconnected"| EBUS
MSG_STORE -->|"message.new\nreceipt.updated"| EBUS
ROUTING -->|"route.table.changed"| EBUS
CM -->|"slot.state.changed"| EBUS
EBUS --> DMR
EBUS --> CONSOLE
EBUS --> SDK_SUB
Диаграмма — Архитектура шины событий
Принципы проектирования:
- ebus передаёт только короткие дельта-события (переходы состояний, счётчики). Никаких тяжёлых данных.
- RPC остаётся для команд/запросов (fetch сообщений, отправка сообщений, snapshot таблицы маршрутизации). RPC-обработчики могут публиковать ebus-события как side-эффект.
- Каждый подписчик получает выделенную drain-горутину с 64-слотовым буферизованным inbox. Издатели никогда не блокируются.
- Подписки регистрируются до startup, чтобы не потерять события.
- Слой ноды полностью автономен — ebus это механизм уведомлений, а не канал управления.
Топики (определены в internal/core/ebus/topics.go): peer.connected,
peer.disconnected, peer.health.changed, peer.pending.changed,
peer.traffic.updated, slot.state.changed, route.table.changed,
message.new, receipt.updated, message.sent, message.send.failed,
message.control, message.delete.completed,
file.sent, file.send.failed, file.received,
contact.added, contact.removed,
identity.added, aggregate.status.changed, version.policy.changed.
Топик message.delete.completed несёт payload MessageDeleteOutcome
(target ID, peer, status, abandoned-flag, attempts), чтобы UI-подписчики
могли отличить успешное удаление на стороне получателя от
denied / immutable / abandoned — все четыре исхода неотличимы на уровне
wire. См. контракт control-DM в dm-commands.md;
этот топик — единственный observable, по которому драйвится eviction
строки в чате и переход состояния кнопки Delete на file-вкладке.
file.received публикуется, когда DMRouter регистрирует receiver-mapping
из входящего decrypt'а file_announce, независимо от того, активен ли
сейчас этот разговор — без этого события Desktop-вкладка файлов
пропускала бы входящие строки, прибывшие в неактивные чаты.
Подписчики ebus (в первую очередь Desktop NodeStatusMonitor) на каждое
событие перестраивают полный snapshot и инвалидируют все окна, которые его
наблюдают. Когда один входной сбой — например каскад i/o таймаутов после
cm_session_setup_failed — разливается в десятки переходов состояния пиров,
приводящих к одному и тому же агрегату, пакет идентичных событий
проявляется как зависший UI: drain-горутины заняты, но каждый
пересобранный snapshot побайтно совпадает с предыдущим.
Чтобы такой шторм не доходил до шины, два топика, которые переносят
полные content-snapshot'ы, защищены no-op гейтом на стороне издателя в
паре с периодическим heartbeat-пересинхроном. Heartbeat обязателен:
Publish в ebus намеренно сбрасывает async-доставку, если inbox
подписчика заполнен (издатель никогда не блокируется), поэтому чистый
content-based dedup оставил бы подписчика, у которого первая публикация
потерялась, навсегда устаревшим. Heartbeat ограничивает это устаревание
известным окном.
version.policy.changed—recomputeVersionPolicyLockedсравнивает новыйVersionPolicySnapshotс предыдущим напрямую через==(все поля comparable). Публикация происходит при изменении контента, на первом recompute (bootstrap) или когда с момента последней публикации прошлоversionPolicyHeartbeatInterval. Интервал heartbeat выровнен сversionPolicyRepairInterval, поэтому существующий periodic-repair тик bootstrapLoop сразу служит драйвером heartbeat — отдельная планировка не нужна.aggregate.status.changed—publishAggregateStatusChangedLocked— единственная точка публикации этого топика. Сравнение идёт противlastPublishedAggregateStatus(который отражает то, что реально увидели подписчики, отдельно отaggregateStatus, потому что пути init и orphan-eviction мутируют последнее без публикации) черезAggregateStatusSnapshot.EqualContent, игнорирующий heartbeat-полеComputedAt. Публикация происходит при изменении контента, на первом вызове или когда прошлоaggregateStatusHeartbeatInterval. 2-секундный тикерbootstrapLoopвызываетrefreshAggregateStatus(), который проходит через тот же helper — именно тикер делает heartbeat наблюдаемым. ПереносComputedAtвstatus.CheckedAtвNodeStatusMonitorзаодно удерживает пользовательский индикатор "last checked" в движении на тихой, но здоровой ноде — он не замирает, когда перестают двигаться счётчики агрегата.slot.state.changed— публикации НЕ дедуплицируются на стороне издателя. Потерявшая способность доставки ebus в сочетании с publisher-side memo навсегда оставила бы любого подписчика, чья единственная публикация по состоянию слота была сброшена; подписчик не сможет восстановиться, потому что дешёвого heartbeat, который несёт полную map per-slot состояний без привязки издателя к жизненному циклу подписчика, не существует. Переходы слотов — это различимые события в ограниченной state machine, поэтому безусловная публикация сохраняет корректность. Случайное дублирование поглощает downstream delta-фильтр вNodeStatusMonitor.applySlotStateDelta.
Гейт — ответственность издателя, а не подписчика: фильтрация на стороне подписчика всё равно оплачивает диспатч события и fan-out по всем drain'ам. Гейт на издателе — вместе с heartbeat, компенсирующим потерю доставки — сохраняет честность семантики ebus: наблюдаемое событие означает либо изменение контента, либо периодический ресинхрон.
Типы, живущие на границе read-only snapshot (NodeStatus, PeerHealth,
CaptureSession, DirectMessage, PendingMessage), никогда не
используют *time.Time для опциональных timestamp-полей. Используется
value-тип domain.OptionalTime, объявленный в
internal/core/domain/optional_time.go.
Почему так:
- Pointer snapshot это не deep copy. Копирование структуры с
*time.Timealiases the pointee. UI-горутина, читающая snapshot, и фоновая горутина, мутирующая источник, видят одно и то же время через разделяемую память — snapshot-контракт молча нарушается. OptionalTimeэто значение.struct { t time.Time; valid bool }копируется по значению. Snapshot становится настоящим deep copy — UI не может наблюдать мутации write-пути.- Опциональность видна из типа. Zero value
OptionalTimeозначает "нет значения", это отличается отtime.Time{}("эпоха"). Правило проекта "отсутствие значения должно быть видно из типа, а не угадываться по пустому значению" закрывается структурно.
Ebus-пейлоады (например ebus.PeerHealthDelta.LastConnectedAt,
ebus.CaptureSessionStarted.StartedAt) оставляют *time.Time, потому
что на delta nil имеет отдельную семантику: "эта delta не обновляет
это поле". Этот смысл теряется, если поле становится value-типом
с конвенцией "zero = missing". Дельты попадают в snapshot-границу
только через NodeStatusMonitor.applyX(...), где входящие указатели
преобразуются в OptionalTime через domain.TimeFromPtr(...) в
момент применения. Дальше состояние живёт только как значения.
API domain.OptionalTime:
TimeOf(t time.Time) OptionalTime— конструктор из конкретного времениTimeFromPtr(p *time.Time) OptionalTime— копирует pointee (возвращает невалидное значение, еслиp == nil)TimeFromNonZero(t time.Time) OptionalTime— возвращает невалидное значение приt.IsZero(), полезно для wire-leveltime.Time{}Valid() bool—true, если значение установленоTime() time.Time— возвращает время (zero value, если невалидно)Ptr() *time.Time— аллоцирует свежий pointee на каждый вызов (безопасно отдавать ebus-дельте, aliasing невозможен)Equal,Before,After,Sub— value-safe сравнения
- показать trust conflicts в desktop UI
- добавить подписи для
Gazetanotices - перейти от line protocol к structured frames
добавить персистентное хранение истории сообщений— сделано, см. chatlog.md- сделать mobile/light-client bindings поверх того же core