Architecture Decision Records — Native Telegram Client (Rust)
Источник:
dev/fera/project-design/context/ADR.md(read-only; сгенерировано scripts/sync-docs.mjs)
Architecture Decision Records — Native Telegram Client (Rust)
Section titled “Architecture Decision Records — Native Telegram Client (Rust)”Status legend: Accepted | Proposed | Superseded
ADR-001: Modular micro-kernel architecture with hot-swappable native modules
Section titled “ADR-001: Modular micro-kernel architecture with hot-swappable native modules”Status: Accepted
Context: The project intends to be heavily, repeatedly re-optimized and re-architected over its lifetime. A monolithic binary would make swapping individual subsystems (storage engine, search engine, renderer, protocol client) prohibitively expensive.
Decision: The application is split into a thin launcher binary and a
set of hot-swappable native modules (dynamic libraries) communicating
through versioned, C-ABI-stable trait contracts. Module loading/unloading is
handled by nylon-ring (explicit version + struct-size validation on load,
graceful unload with in-flight call draining, host-leased zero-copy output
buffers).
Consequences:
- Any heavy subsystem (storage, search, protocol, media decode, UI renderer, UI style, UI platform-feature layer, predictor) can be replaced without recompiling or restarting the whole application (except the core module itself, see ADR-013).
- Every module boundary must be designed as a trait contract (interface crate) shared between host and module, not as ad-hoc FFI.
- Contract tests (see ADR-014) become mandatory infrastructure, not optional — without them there’s no guarantee a swapped-in implementation actually honors the trait’s semantics.
- Cross-boundary calls have real overhead (vtable dispatch, no inlining, marshaling); hot paths must be designed to minimize how often they cross the boundary (see ADR-005).
ADR-002: Two-tier trust model for native modules — official vs third-party plugins
Section titled “ADR-002: Two-tier trust model for native modules — official vs third-party plugins”Status: Accepted
Context: Native dylib modules are fast but unsafe to load from untrusted sources (no sandbox). Community modding is a stated goal.
Decision:
- First-party / performance-critical modules (storage, search, protocol,
media decode, UI engine/style/feature, predictor) are native dylibs loaded
via
nylon-ring, signed and verified against a self-hosted TUF-like trust manifest (see ADR-003). No sandbox — these are trusted-by-design, hash mismatches produce a warning, not a load refusal (see ADR-004). - Third-party community plugins are WASM modules loaded via
wasmtime(+wit-bindgen/Component Model for typed contracts), sandboxed by construction. Capability-gated: read-only by default, write/action capabilities (send messages, mutate UI, network access) require explicit per-capability opt-in in settings.
Consequences:
- Two separate loading/validation pipelines exist in the codebase; they must not be conflated.
- The ~30% performance overhead of WASM is an accepted tradeoff for portability + sandboxing on the plugin tier only. Native modules pay no such tax.
ADR-003: Self-hosted trust manifest instead of third-party identity provider
Section titled “ADR-003: Self-hosted trust manifest instead of third-party identity provider”Status: Accepted
Context: Module/plugin authenticity needs to be verifiable. Sigstore/cosign
(OIDC-based keyless signing) was considered but rejected as infrastructure-
heavy for a desktop client. GitHub-hosted SSH keys (github.com/<user>.keys)
were considered but rejected as an unnecessary hard dependency on a specific
third-party service for a core trust primitive.
Decision: Ship a minimal TUF-like trust model:
- A root Ed25519/SSH keypair (ours) is embedded in the launcher binary.
- We publish a signed manifest (module name → hash → signature) as plain JSON, hosted on our own infrastructure (site + repo, optionally mirrored elsewhere for independent verification).
- Users manage a local trusted-key list via an in-app UI: name + URL or hex-encoded public key, addable/removable. No external identity provider is required for a user to trust their own or a third party’s key.
- Optional, non-blocking secondary verification (e.g. cross-checking against a GitHub-hosted key) may be offered as a convenience feature, not a requirement.
Consequences:
- We own and must maintain the manifest signing/publishing pipeline.
- Web-of-trust (users signing other users’ keys directly) is out of scope for v1, tracked as a future direction (see OQ document).
ADR-004: Hash mismatch is a warning, not a hard gate
Section titled “ADR-004: Hash mismatch is a warning, not a hard gate”Status: Accepted
Context: Locking module loading behind a strict signature check would undermine user freedom to modify/optimize their own build, which is an explicit design goal.
Decision: On module load, if the on-disk hash doesn’t match the trusted manifest entry, show a non-blocking warning (“library hash does not match the known version — if you didn’t change it yourself, check your system for malware”), not a refusal to run.
Consequences:
- The trust manifest signature (ADR-003) is the real security boundary against supply-chain tampering of the manifest itself — the hash check alone is trivially bypassable by an attacker who also controls the manifest, hence the manifest must itself be signed.
- Users can freely self-modify/replace any module for experimentation.
ADR-005: Batch APIs + host-side hot cache to amortize module boundary overhead
Section titled “ADR-005: Batch APIs + host-side hot cache to amortize module boundary overhead”Status: Accepted
Context: Storage/search calls happen at high frequency (e.g. chat scrolling). Crossing a dylib boundary per-call is measurably more expensive than an in-process call.
Decision:
- Module trait contracts expose range/batch operations
(
get_range(chat_id, from, limit) -> Vec<Message>), not single-item calls, aligned to natural pagination boundaries. - A host-side (in-process) LRU cache of recently-viewed messages sits in front of the storage module; the vast majority of scroll-driven reads hit this cache and never cross the module boundary. Only cache misses issue a batched call to the storage module.
- Output buffers are host-leased where the module runtime supports it
(
nylon-ring), avoiding double-copy across the boundary.
Consequences:
- Storage/search trait design must be batch-first from day one; retrofitting is expensive.
- The host-side cache invalidation strategy must be defined per data kind (messages, chat list, search results).
ADR-006: Actor model (ractor) for all non-UI subsystems
Section titled “ADR-006: Actor model (ractor) for all non-UI subsystems”Status: Accepted
Context: Considered plain async/tokio tasks + channels vs. a formal actor framework vs. a separate OS process for protocol isolation.
Decision: Use ractor for protocol, chat, media, session/auth, and
predictor subsystems. One actor (or actor pool) per concern; chat state is
modeled as one actor per chat (or per active-chat pool) for isolation and
crash/restart semantics. Actors run in-process, sharing the tokio runtime
with the protocol client (grammers is async-native).
Rejected alternatives:
- Separate OS process for protocol isolation (à la Telegram Desktop’s MTProto workers) — rejected for v1 due to IPC serialization overhead and deployment complexity, with no concrete benefit beyond what actor supervision already provides. May be revisited later specifically for sandboxing media decoders, not the whole protocol stack.
actix— rejected as heavier / tied to an ecosystem (actix-web etc.) not needed here.
Consequences:
- Inter-actor messages use raw Rust types, not serialized — actors share a process and serialization would be pure overhead (see ADR-007).
ADR-007: No serialization between in-process actors
Section titled “ADR-007: No serialization between in-process actors”Status: Accepted
Context: Initially considered rkyv for all inter-component communication for consistency.
Decision: Actor-to-actor messages (ractor mailboxes) use raw Rust types.
rkyv (zero-copy) is reserved for actual serialization boundaries: on-disk
storage, module (dylib) boundary payloads where applicable, and network wire
formats where MTProto/grammers types don’t already dictate the format.
Consequences: Simpler, faster in-process messaging; no accidental performance tax from a “serialize everything for consistency” policy.
ADR-008: Elm-style unidirectional update loop for central UI state, native GPUI models for local UI mechanics
Section titled “ADR-008: Elm-style unidirectional update loop for central UI state, native GPUI models for local UI mechanics”Status: Accepted
Context: GPUI’s native reactive Model<T> + callback-mutation style is
closer to MVVM/observer than to a single-point-of-truth update loop. Given
the project’s expectation of frequent architecture churn and its actor-based
backend, a predictable, testable state-transition story was prioritized.
Decision:
- Central application state (chat list, active chat, message
send/receive state, anything that talks to actors) is managed through an
Elm-style loop:
update(&mut State, Msg) -> Vec<Effect>, a pure function, independently unit-testable without a GPUI context.Effects are the vocabulary used to talk to actors. - Local, ephemeral UI mechanics (hover states, tooltip visibility, a
single dropdown’s open/closed state) remain native GPUI
Models/callbacks — not routed through the centralMsgtype, to avoid boilerplate for purely local concerns. - The renderer itself is further built as a Higher-Order-Functions (HoF) /
immediate-mode-style composition layer on top of GPUI, rather than using
GPUI’s
View/Elementtrait-object model directly as the primary authoring surface.view: &State -> RenderTreeis expected to be built compositionally from HoF-style component functions.
Consequences:
- Two state-management idioms coexist by design (central Elm loop vs. local GPUI models); the boundary (what’s “central” vs “local”) must be kept consistent as the codebase grows.
- The HoF rendering layer is itself a candidate for the UI-Engine hot-swap axis (ADR-009) — it must be designed decoupled enough from GPUI internals to eventually target alternative engines.
ADR-009: Three independent hot-swappable axes for UI — Engine / Style / Feature
Section titled “ADR-009: Three independent hot-swappable axes for UI — Engine / Style / Feature”Status: Accepted (amended by ADR-023: a fourth axis, Native, was added; amended by ADR-028: Engine substrate decided as Vello+Parley; amended by ADR-029: Style is declarative data, not a dylib — the axis split is redefined as three native dylib axes (Engine / Feature / Native) plus Style as a data layer)
Context: Native platform look-and-feel differs meaningfully across macOS/Windows/Linux — not just visually, but behaviorally (scroll physics, OS integration idioms). A single monolithic renderer would force either a lowest-common-denominator look or large amounts of per-platform branching inside one codebase.
Decision: UI is split into three independently hot-swappable module
axes, all behind the same nylon-ring native-module mechanism as other
first-party modules:
- Engine — how rendering happens (e.g.
renderer-gpui.so; future alternative engines are possible). - Style — visual language/tokens (colors, type, spacing, per-OS aesthetic), data-heavy, engine-agnostic where possible.
- Feature — OS-integration behaviors (e.g. rubber-band scroll on macOS, snap-scroll on Windows, native menu/notification-center integration). Feature modules are expected to hook into / modify Engine behavior (e.g. intercept scroll handling), not operate fully independently.
Consequences:
- The “official triads” per OS (one Engine + one Style + one Feature set) are the primarily tested/supported combinations; arbitrary user-mixed combinations are best-effort.
- Feature-local state (e.g. overscroll spring physics) lives inside the Feature module’s own local state, not routed through the central Elm loop (consistent with ADR-008’s local/central split).
- Whether Engine/Style truly need separate dylibs (vs. Style being data/config loaded by one Engine) is an explicit open question — see OQ document. (Resolved by ADR-029: Style is data.)
ADR-029: Style is declarative data, not a dylib
Section titled “ADR-029: Style is declarative data, not a dylib”Status: Accepted
Context: OQ-001. Whether the Style axis needs its own hot-swappable native dylib (like Engine/Feature) or is just data loaded by an Engine.
Decision: Style = declarative data (tokens: colors, typography, spacing, icons), loaded by the Engine. The responsibility split:
- Style (data) — declarative description of appearance. If a style ever needs special logic, the logic is described declaratively in the data but implemented in a dylib.
- Feature (dylib, OS-selected) — that implementation lives in the
dylib axes; OS-specific callbacks/features are selected by OS where
needed — that belongs to the Feature axis (
feature-macos/feature-win/feature-linux), not to Style.
The four axes settle as: three native dylib axes (Engine / Feature /
Native) + Style as a data layer (RON token files, structurally the
same as the Lattice themes/ subtree — an intentional convergence:
theme distribution rides the container’s multi-root mechanics).
Consequences:
- One fewer ABI surface, one fewer signature/version pipeline; theme updates are data pulls (new theme subtree → new root CID), not module swaps.
- “Style logic” is bounded by what the declarative format can reference — arbitrary imperative style code is not expressible, by design; anything behavioral routes to Feature.
- Pixel-diff visual regression (QA) stays per-Style-file-set, scoped per OS aesthetic, unchanged in spirit from the dylib formulation.
ADR-010: Offline-first local storage as source of truth
Section titled “ADR-010: Offline-first local storage as source of truth”Status: Accepted
Context: Official Telegram clients are observed to fail to show cached history when the network is unavailable, despite having the data locally. This is treated as a defect to explicitly design around.
Decision:
- All messages ever seen by the client are persisted locally and treated as the source of truth for reads. The UI/domain layer never reads directly from the network; the protocol layer only populates local storage and notifies of new data.
- Outbound writes (new messages, read-receipts) are persisted locally first,
with a
pending → sent → failedstatus, and retried on connectivity restoration (the outbox pattern). Read-receipt sync reuses the same outbox mechanism as message sending (see ADR-011). - Backward history pagination is a background operation independent of what’s already rendered from local storage; its failure/stall must never block already-cached content from displaying.
Consequences:
- Storage schema must model message status explicitly (see ADR-015, typestate/enum modeling).
- Network reconnection logic (backoff + jitter, health-check separate from MTProto round-trip latency) lives entirely in the protocol actor and must not leak into how the UI/domain layer reads data.
ADR-011: Read-status as optimistic-local + server-confirmed dual state
Section titled “ADR-011: Read-status as optimistic-local + server-confirmed dual state”Status: Accepted
Context: Multi-device read-state sync is server-authoritative in
Telegram’s protocol (read_inbox_max_id / read_outbox_max_id), but local
UI feedback needs to be instantaneous regardless of round-trip latency or
connectivity.
Decision: Chat actor state models read-status as an explicit state machine (not two loosely-coupled booleans/IDs):
enum ReadState { Synced(MessageId), Pending { local: MessageId, server: MessageId }, Offline { local: MessageId, last_known_server: MessageId },}Outbound read-receipt updates are debounced (not sent per-message-seen) and flow through the same outbox/retry mechanism as message sending.
Consequences: Read-state UI can always render optimistically from
local, while server reconciliation is handled uniformly through existing
offline-first infrastructure rather than a bespoke sync path.
ADR-012: Storage & search engine selection (subject to re-evaluation)
Section titled “ADR-012: Storage & search engine selection (subject to re-evaluation)”Status: Accepted (implementation detail, expected to churn)
Decision:
- Storage engine: sanakirja (pure Rust, ACID, MVCC; proven in production
via Pijul) behind the
StorageEnginetrait (ADR-001/005). Chosen over an earlier candidate (ZeroDB — a young, unproven pure-Rust LMDB-compatible reimplementation) due to maturity concerns; the trait boundary makes this swap low-cost by design. - Search engine: a fork of milli (Meilisearch’s embedded search library,
normally LMDB/
heed-backed) re-targeted to run onsanakirjainstead of LMDB, paired with Charabia for tokenization. - Exact-match/regex search: PCRE2 (via the
pcre2crate) for precision queries, with two-phase search —regex-syntaxliteral extraction narrows the candidate set via the FTS index first, then PCRE2 matches only candidates, avoiding a full-corpus regex scan.
Consequences: Because milli’s storage layer assumption (heed API) is
being swapped, the fork must track milli’s heed-facing API surface
carefully across upstream updates. This is accepted as ongoing maintenance
burden in exchange for a fully pure-Rust storage stack.
ADR-013: Thin launcher + core module, self-update via restart-gated replacement
Section titled “ADR-013: Thin launcher + core module, self-update via restart-gated replacement”Status: Accepted
Context: A single self-updating binary would need to replace its own
executable while running — not directly possible (file lock on Windows;
even on POSIX, the running process still holds the old inode). Studied
Discord’s approach (config-driven update endpoint, silent update-on-launch
on Windows, notoriously blocking/manual on Linux) as a negative example to
avoid, particularly its unsigned/user-editable settings.json update
endpoint (exploited by unofficial tools to redirect module downloads).
Decision:
- Ship two binaries: a minimal, rarely-changing launcher (reads the signed trust manifest, verifies/downloads the current core module, loads it, hands off control) and a core module (the entire actual client — actors, storage, UI, everything) treated as “module zero” under the same signed-manifest mechanism as all other first-party modules (ADR-001/003).
- Core module updates are restart-gated (download → verify → replace on-disk file → apply on next launcher start), since a running module cannot safely replace itself. All other first-party modules remain hot-swappable at runtime without a restart, per ADR-001.
- The update manifest/endpoint is part of the signed trust manifest, not a plaintext user-editable config file.
Consequences: The launcher’s job is deliberately minimal and should change rarely, minimizing its own need to self-update. Update UX must be non-blocking regardless of OS (explicitly rejecting Discord’s Linux behavior).
ADR-024: Native module distribution — polling cadence, channels, rollback, dev mode
Section titled “ADR-024: Native module distribution — polling cadence, channels, rollback, dev mode”Status: Accepted
Context: The ADR-021 manifest defines the version/signature model for native modules; the runtime distribution policy (when updates are checked, how they’re staged, what happens on failure, and how local development works) was left open. Companion to OQ-013’s scope decision that Lattice (the CAR/Merkle container) is for WASM plugins only — native modules remain plain signed dylibs.
Decision:
- Update checks: background poll every few hours + an explicit in-app “check now” button + a lazy check at startup (after full init, never blocking boot).
- Hosting: binaries live on the same infrastructure as the signed manifest (our own site/repo; CDN later if needed — see FUTURE-CHECKS §4).
- Channels: a
channelfield in the manifest (e.g. stable / beta / nightly) — one manifest, channel-tagged entries, not one manifest per channel. - No delta updates. Dylibs are aggressively split into small files (the ADR-020 three-tier granularity), so full-file replacement is already cheap; delta machinery would buy little and complicate verification.
- Rollback: keep at most the N-1 module version on disk, and
only while it remains host-compatible (per the manifest’s
host.min/max_version); older versions are garbage-collected. - Developer mode: unsigned/local modules are an explicit opt-in developer mode (an “literally vro”-style toggle — off by default, on only when the user deliberately enables it). This is the same spirit as ADR-004’s warning-not-block: user freedom preserved, but the insecure path is a conscious choice, not a silent default.
Consequences:
- Update flow is non-blocking everywhere: the poll, the button, and the lazy startup check all run after the app is usable; only core-module application stays restart-gated (ADR-013).
- Rollback can never strand the user on an incompatible module: the N-1 copy is validated against the current host before it’s trusted as a fallback.
- Developer mode is the only path where hash-mismatch-as-warning (ADR-004) becomes a routine state rather than a tamper signal; the UI should make “you are running developer modules” visible and persistent, not a one-time toast.
ADR-025: Secret chats — full support in v1, separate storage, MTProto E2E as-is
Section titled “ADR-025: Secret chats — full support in v1, separate storage, MTProto E2E as-is”Status: Accepted
Context: OQ-008. Secret chats conflict head-on with ADR-010’s “persist everything seen, local storage is the source of truth”: a secret chat by nature should not be durably retained — the server deletes by TTL, and locally caching what the server promised to destroy undermines the contract.
Decision:
- Full support in v1 — create/read/write/TTL/media, not a reduced subset. The target audience cares about secret chats; shipping half is worse than shipping full or nothing.
- MTProto E2E as in the official clients: DH-handshake on chat creation, key fingerprint verification via the emoji-picture (both parties eyeball 4 emoji encoding the key hash). No invention of our own E2E.
- Device migration follows the official model: no key transfer — a secret chat is per-device; a new device creates a new secret chat. History does not carry over.
- Storage: separate sanakirja instance, not a tier inside the main one — different retention policy (TTL deletion job), different question on passcode change, different backup/compaction posture.
- Key derivation: same user passcode, different Argon2id context
string (e.g.
"fera-secret-v1") — separate derived key, no new unlock UX. - Outbox/ReadState reuse ADR-010/011 machinery, with an explicit policy decision on pending secret messages that outlive an offline restart (drop vs. keep-with-warning — to be settled in the secret chat implementation pass).
Consequences:
- The offline-first guarantee is deliberately qualified for secret chats: locally cached secret history is a TTL-scoped courtesy, not a permanent source of truth.
- A second storage instance doubles the encryption-tier bookkeeping (OQ-005’s field audit must cover both).
- gram-level: grammers’ secret-chat support must be audited for completeness before v1 scope is frozen (see ADR-026 for the protocol library decision).
ADR-026: Protocol library — fork grammers, own library on top of its sources
Section titled “ADR-026: Protocol library — fork grammers, own library on top of its sources”Status: Accepted
Context: Full v1 scope (secret chats ADR-025, passkeys ADR-027)
exceeds what vanilla grammers exposes. The options were: upstream
contributions, wrapper-only usage, or a fork.
Decision: Fork grammers and build our own library on top of its sources — reshaped for Fera’s tasks and more complete where the project needs it (secret chats, passkey auth, typestate surface per ADR-015).
Consequences:
- We own protocol maintenance: tracking MTProto changes becomes our job, decoupled from upstream’s release cadence.
- The fork is expected to drift from upstream API; treat upstream as an upstream-source to mine, not a dependency to stay compatible with.
- The project gains freedom to break API for our own typestate/newtype design (ADR-015) without upstream negotiation.
ADR-027: Passkeys — WebAuthn login in v1; secret-chat encryption-by-passkey in v1.5
Section titled “ADR-027: Passkeys — WebAuthn login in v1; secret-chat encryption-by-passkey in v1.5”Status: Accepted
Context: Telegram shipped passkeys (Dec 2025) as an SMS-replacement login method (Settings > Privacy and Security > Passkeys; device-stored or password-manager-synced WebAuthn credentials; SMS remains fallback). The author wants passkey support, and separately floated using passkeys as an encryption factor for secret chats — a beyond-spec feature, distinct from anything official clients do.
Decision:
- v1: passkey login — full WebAuthn passkey registration and authentication as a login method (alongside SMS), with in-app management UI (list, creation date, last used). Delivered through the grammers fork (ADR-026).
- v1.5: passkey as an encryption factor for secret chats — above-and-beyond feature, explicitly not official-client behavior, separate from login passkeys. Design deferred until v1.5; do not design now.
Consequences:
- Login passkeys are protocol-level work (WebAuthn flows against
Telegram’s servers) and must fit the typestate login lifecycle
(ADR-015
Unauthenticated → Authenticated). - The v1.5 encryption-factor idea needs its own design pass (what the passkey wraps: a second secret-chat key? a key-wrapping key?) — parked until then.
ADR-014: Testing strategy — contract tests as the load-bearing safety net for modularity
Section titled “ADR-014: Testing strategy — contract tests as the load-bearing safety net for modularity”Status: Accepted
Decision:
- Contract tests: one test suite per trait (
StorageEngine,SearchEngine,MediaDecoder, rendererEngine, etc.), run against every implementation (first-party swaps and, where feasible, third-party ones) to guarantee trait-semantic compliance independent of hot-swapping. - Actor tests: via
ractor’s test harness, per actor in isolation. - Elm
updatetests: pure-function unit tests, no GPUI context required. - Module-loading tests: version-mismatch fallback behavior, hash-mismatch warning (non-blocking) behavior, hot-reload-under-load (no dropped in-flight calls).
- Visual regression: pixel-diff, scoped per Style module.
- Performance:
criterionbenchmarks, scoped per Engine module, run against a fixed benchmark scene (see OQ document for scene parameters).
Consequences: Contract tests are treated as required infrastructure before any second implementation of a given trait is accepted, not optional follow-up work.
ADR-015: Type-driven invalid-state elimination (“parse, don’t validate”)
Section titled “ADR-015: Type-driven invalid-state elimination (“parse, don’t validate”)”Status: Accepted
Context: Explicit design goal: push as many invariants as possible into the type system so that invalid states are unconstructible, reducing the runtime-validation test surface.
Decision: Apply, where the underlying invariant is real and frequently violated (not decoratively):
- Typestate pattern for the protocol client’s connection lifecycle
(
ProtocolClient<Unauthenticated>→<Authenticated>→<Connected>); operations likesend_messageare only defined on states where they’re valid, enforced at compile time. - Newtype + smart constructors for IDs and bounded values crossing the
network/domain boundary (e.g.
MessageId(NonZeroU64),Confidence(f32)validated at construction). - Enums over boolean/Option flag combinations for multi-state data (read
status per ADR-011, outbox message status
pending/sent/failed). - Phantom types for the
Encrypted<T>/Plaintext<T>boundary (ADR-016) so the storage layer cannot accidentally persist unencrypted data where a trait signature demandsEncrypted<T>. frunk(HList and related typeful-programming utilities) where heterogeneous, statically-known-shape data benefits from it.
Consequences: Applied selectively to invariants with real, recurring violation risk (auth flow, message/read status, encryption boundary) — not applied uniformly, to avoid unproductive type-complexity explosion.
ADR-016: Tiered encryption — lightweight/GPU-friendly for metadata, strong AEAD for content
Section titled “ADR-016: Tiered encryption — lightweight/GPU-friendly for metadata, strong AEAD for content”Status: Accepted
Decision:
- Metadata requiring fast search across hundreds of chats (e.g. chat list entries): lightweight, hardware-accelerable cipher — ChaCha20 or AES-GCM (AES-NI-accelerated).
- Message/media content: XChaCha20-Poly1305 (extended nonce, safer at high message volume), key derived via Argon2id from the user’s passcode, optionally backed by OS keychain for convenience.
- The
Encrypted<T>/Plaintext<T>phantom-type boundary (ADR-015) enforces at compile time that content can’t be persisted without going through the appropriate tier’s encryption path.
Consequences: Two distinct encryption code paths must be maintained; which data belongs to which tier must be decided explicitly per field, not inferred, particularly for metadata vs. content ambiguous cases (e.g. does a chat’s last-message-preview count as metadata or content?) — flagged as an open question.
ADR-031: Encryption field audit — per-field tier assignments; searchable encryption for secret chats
Section titled “ADR-031: Encryption field audit — per-field tier assignments; searchable encryption for secret chats”Status: Accepted
Context: OQ-005. The ADR-016 tiering needed a concrete field-by-field assignment; ADR-025 added the second (secret-chat) sanakirja instance that also needed auditing.
Decision:
- Field audit, main instance:
- Metadata tier (lightweight cipher): chat/message IDs, timestamps,
counters,
ReadState, outbox status + retry info, chat-list entries (titles, chat type, pinned/muted, unread counts), storage indexes, predictor event log. - Content tier (XChaCha20-Poly1305): message text + entities, media blobs + captions, profile contact info / bio, search-index terms.
- Last-message-preview: content tier. A preview leaks message content; nothing searches over it (search uses the full index), so its speed is not latency-critical.
- Search-index terms are content tier — the milli fork indexes plaintext, so terms inherit the content classification; the search-while-encrypted question is handled separately (below).
- Metadata tier (lightweight cipher): chat/message IDs, timestamps,
counters,
- Secret-chat instance (ADR-025): everything is content tier under
the instance’s own key (Argon2id context
"fera-secret-v1") — including nominal “metadata” (names, dates), because the existence of a secret chat is itself sensitive. TTL deletion rides on top. - Searchable encryption for secret chats (opt-in by design):
deterministic HMAC tags — at write time, per term:
H = HMAC(key_search, term); identical terms produce identical tags; tags form the inverted index (milli sees only tags). At query time the same HMAC is computed to search. The index never holds plaintext; withoutkey_searchno query tag can be generated.- Known limitation, accepted: deterministic tags admit frequency analysis (“the most frequent tag is ‘the’”) — dictionary attacks on the tag space. Mitigations (salted pairs) exist but add complexity; treat as a hardening direction, not a launch blocker.
- The main index stays plaintext-at-rest-with-disk-encryption by default (speed); the same searchable-encryption machinery may be offered as a “paranoid mode” option for the main instance later.
- The author flags this as a new potential attack vector — recorded here so it is designed and reviewed with that skepticism, not adopted blindly.
Consequences:
- The audit table lives in this ADR and is the reference for QA’s “explicit encryption-tier answer per persisted type” gate.
- New persisted fields require an audit-table entry before merge (extends the cross-cutting gate in QA.md).
- Secret-chat search works only when the user unlocks (has the key);
the tag index is per-instance and its tags do not correlate with the
main instance’s index (separate
key_search).
ADR-017: Video/media decoding — pure-Rust baseline, optional FFmpeg module for full format coverage
Section titled “ADR-017: Video/media decoding — pure-Rust baseline, optional FFmpeg module for full format coverage”Status: Accepted
Decision: Ship pure-Rust decoders (rav1e/dav1d for AV1, openh264
for H.264, symphonia for audio/Opus voice messages) as the built-in
MediaDecoder default, covering the common case with clean licensing and no
C dependency. An official, signed, optional ffmpeg-decoder module
(wrapping ffmpeg-next) is offered as an opt-in download through the same
module-distribution mechanism (ADR-001/003) when a user encounters a format
the built-in decoder can’t handle.
Consequences: Default distribution stays free of FFmpeg’s licensing concerns; full-format-coverage users take on that dependency explicitly and knowingly.
ADR-018: Voice/video calls — official tgcalls bindings now, pure-Rust reimplementation deferred
Section titled “ADR-018: Voice/video calls — official tgcalls bindings now, pure-Rust reimplementation deferred”Status: Accepted
Context: Telegram’s calling stack (tgcalls) is not a documented,
stable public protocol like MTProto itself; reimplementing it from scratch
carries high risk of breakage on undocumented protocol changes.
Decision: Ship official tgcalls via C++ FFI bindings (own binding
crate, rstgcalls) for calls/streams in the initial release line. A
from-scratch pure-Rust reimplementation is explicitly deferred to a later
major version and tracked as a long-term goal, not a launch blocker.
Consequences: Calls/streams pull in a C++ dependency (WebRTC via tgcalls) that the rest of the stack otherwise avoids; this is accepted as scoped and isolated to the calling module specifically.
ADR-019: Download-prediction / prefetch heuristics as a tiered, shared-infrastructure system
Section titled “ADR-019: Download-prediction / prefetch heuristics as a tiered, shared-infrastructure system”Status: Accepted
Decision: A PredictorActor logs chat-activity events (opened / closed / scrolled / message_sent, timestamp, session duration) to local storage.
Three tiers share this same event log as their data source, built
incrementally:
- Tier 1: naive per-user statistics — open-time histograms (time-of-day/day-of-week), co-occurrence pairs (“chat A opened → chat B likely opened soon”), top-N chat prefetch. No ML.
- Tier 2: contextual features (time, day, chat type) feeding a simple model (e.g. logistic regression / naive Bayes) predicting P(open in next N minutes), trained per-user on the accumulated Tier 1 log.
- Tier 3: online-learning refinement (e.g. bandit-style prefetch allocation), deferred until Tier 1/2 produce enough real usage data to validate against. Prefetch requests are always dispatched at lower priority than explicit user-driven fetches (e.g. active scrolling), enforced at the media pipeline level.
Consequences: Tiers 2/3 are explicitly not to be built until real telemetry from Tier 1 exists to validate them against — this is a sequencing decision, not just a feature-scope one.
ADR-020: Three-tier module granularity for domain structures — shared-types / for-struct-types / struct
Section titled “ADR-020: Three-tier module granularity for domain structures — shared-types / for-struct-types / struct”Status: Accepted
Context: Struct-per-dll granularity was challenged over per-call boundary overhead. Counter-evidence: game engines routinely run gameplay logic across DLL boundaries at far higher call frequencies (physics, audio, per frame, under 16ms whole-frame budgets) without collapsing into monoliths — and pay extra for batching only where measured. The chat-client domain operates orders of magnitude below those frequencies, so boundary overhead is not a blocking concern at this granularity; batching remains a tool for measured hot spots (storage/search, ADR-005), not a precondition for all modules.
Decision:
- Modules may be cut at the granularity of individual domain structures,
organized in three tiers:
shared-types— the cross-module common language (MessageId,ChatId,ReadState): types referenced by several struct modules, declared once with explicit dependencies, never duplicated per-module.for-struct-types— types/initializers local to one structure.struct— the structure plus its entire impl (behavior) as one physical seam; a type’s methods are never split across modules.
- The manifest declares
layer: shared | types | implanddepends_on; load order is derived topologically (shared first, types second, impls last). - Flat, simple, hot-path types are
#[repr(C)]structs passed by value across the ABI — no opaque handles that would force a boundary call per field access. - Cohesion beats uniformity: strongly-connected structures (Message ↔ Chat ↔ ReadState, which constantly hold/mutate each other) share one module rather than being cut apart; genuinely independent structures (Message vs TrustManifestEntry) stay separate.
Consequences:
- Hot-swap granularity is the whole type — all impl methods swap together; per-method runtime replacement is explicitly not a goal.
- Module count grows; manifest layering and the dependency graph become load-bearing metadata (load-order correctness), not decoration.
shared-typesreduces real overhead versus per-struct type duplication: common types cross one stable ABI contract instead of being re-declared with version-drift risk per module.
Naming addendum (2026-09-30): the for-X-types prefix is dead —
ugly. Layers are named common.dll (shared types), X-types.dll
(module-local types), X.dll (impl). Directory tree: layer0/common,
layer1/X-types, layer2/X, subsystems/ (engine/feature/native/
storage/search/media), core.dll, trust-manifest.json.
Link-time dispatch addendum (2026-09-30): modules export tables
of pointers (static EXPORT: &VTable); the host resolves and patches
importer slots at link time. Per-call cost is a single indirect call
(call [rax]) — a virtual-function call without vtable walking. Flat
#[repr(C)] by-value types unchanged. Relink on hot-swap uses
ERB (epoch-based reclamation) — new pointer table in a new epoch,
old epoch drains before free (see OQ-021 for the deferred design pass).
ADR-021: Version model — abi_version / api_edition / module_version; hardened manifest schema
Section titled “ADR-021: Version model — abi_version / api_edition / module_version; hardened manifest schema”Status: Accepted
Context: Editions are custom but modeled on Rust editions: a set of minimum features the project guarantees to support in the current edition, plus edition N-1 support as long as there are no super-breaking changes. Native modules and WASM plugins are versioned separately; only WASM plugins ever go through translation (ADR-022). A detailed per-entry manifest sketch was proposed and hardened.
Decision:
- Three independent version axes, kept strictly separate:
abi_version— binary layout at the module boundary (struct sizes, calling convention).api_edition— contract semantics; the host guarantees support for editions in the range [N, N-1] where possible.module_version— semver of the implementation itself. Which party bumps which axis is recorded per contract from day one.
- Manifest entry schema (per module), extending the original sketch:
host: { min_version, max_version },target: { triple, runtime, features },artifacts(an array — one module may ship CPU-feature variants such asavx2and baseline, selected by CPU detect at launch),fallback,depends_on+layer(ADR-020), andsignature: { algorithm, key_id, value }. - Manifest hardening rules:
- The signature covers the whole entry in canonical serialization,
not just the artifact hash — otherwise
host.max_versionorfallbackcan be substituted without breaking the signature. - Downgrade protection: a minimum allowed version per module, plus a manifest timestamp/expiry, so an old signed-but-vulnerable module cannot be replayed indefinitely.
- Key revocation, keyed by the existing
key_id.
- The signature covers the whole entry in canonical serialization,
not just the artifact hash — otherwise
Consequences:
- The on-disk data format is versioned independently of API editions — a module swap does not imply a storage migration, and vice versa.
- The host maintains compatibility surfaces for edition N-1 (adapters) — an accepted per-edition maintenance cost.
- Data-model mapping across the axes (KV-shaped traits vs richer contracts) stays a live concern — see FUTURE-CHECKS §1/§2 for the SQL layer discussion.
ADR-022: Edition translators for WASM plugins — two levels; rights narrow only
Section titled “ADR-022: Edition translators for WASM plugins — two levels; rights narrow only”Status: Accepted
Context: Old plugins written against older editions need a compatibility path. A translator is a separate module type: loaded without special rights, it returns a virtual list of plugins to execute; rights are controlled through that virtual list. Translation chains are determined by the translator itself (v1→v2→v3 where no direct v1→v3 exists, or direct where available). Translators exist only for WASM plugins — native modules are versioned and loaded natively (ADR-021), never WASM-translated.
Decision:
- Level 1 — deterministic declarative mapping (default, trusted): the translator is data, not code — a mapping table (call renames, field re-laying, default values, transformations from a small built-in converter set) interpreted by the host. Chains are statically validated at load time (cycles, depth), so no timeout or sandbox is needed. Loaded without special permissions.
- Level 2 — behavioral translators (opt-in, flagged): for what a
mapping table cannot express — e.g. translating changed asynchronicity
(sync ↔ async/streaming). Level 2 is:
- declared per function via a special flag, never blanket-applied to the whole translated surface;
- gated on a separate explicit user opt-in per capability in settings;
- still WASM-sandboxed and fuel-limited.
- Rights model (both levels):
- The host reads rights from the signed package manifest, never from translator output. Capability names are stable, host-defined identifiers — translators map calls, not capabilities.
- Final permissions = original plugin’s requested ∩ user-granted. A translator may rename a capability from the old API to the new, or drop it — never add.
- The rights/manifest schema is frozen from the first stable version and changes additively only: new capabilities are added, existing ones never change meaning; unknown capabilities are must-understand (the host refuses to grant rather than silently ignoring).
- The UI shows the original plugin’s requested rights, not the translated ones.
- Plugin container format: deferred to the author’s own design pass (see OQ-013).
Consequences:
- The host must implement the Level 1 mapping-table interpreter — core infrastructure, not a plugin-side concern.
- Level 2 is the escape hatch, not the common path: if a translation needs Level 2 for the bulk of calls, the plugin is a rewrite candidate, not a translation candidate.
- Behavior changes (sync → streaming) are only possible with Level 2 plus explicit user consent — old plugins degrade audibly rather than silently changing behavior.
ADR-023: Fourth hot-swappable UI axis — native platform components
Section titled “ADR-023: Fourth hot-swappable UI axis — native platform components”Status: Accepted
Context: A native components layer was proposed with macOS as the example (driving SwiftUI from a separate library), then explicitly generalized: the same axis exists for GTK, Qt, WinUI — one instance per platform, macOS is merely the first example.
Decision:
- UI gains a fourth hot-swappable axis alongside Engine/Style/Feature
(ADR-009): Native — platform-native components
(
native-macos,native-linux-gtk,native-qt,native-winui, …). - The contract is thin: a fixed set of slots (text input field, sidebar, menus, dialogs, popups) — not an abstraction over whole widget trees.
- The decisive per-platform design question is who owns the window:
- macOS: native views embed into the window owned by our renderer —
SwiftUI via a Swift shim dylib exporting
@_cdecl(creating anNSHostingViewinside ourNSView), AppKit directly viaobjc2. - Linux (especially Wayland) / WinUI: often the reverse — the toolkit owns the window and our renderer lives inside as a child view.
- macOS: native views embed into the window owned by our renderer —
SwiftUI via a Swift shim dylib exporting
- Highest-value slot first: native text input — IME, spell-check, emoji picker and selection come free, and input is the heaviest part of a custom renderer.
- The message list stays on our own renderer: mixing native views and GPU content inside one scroll container (z-order, scroll sync, focus) is explicitly avoided.
Consequences:
- Four swap axes must not silently collapse into three: Engine / Style / Feature / Native are separate modules and separate swap decisions.
- Per-platform surface grows (one module per toolkit), but each stays small and slot-shaped.
- SwiftUI has no C ABI — it lives in Swift only — so
native-macosnecessarily carries a Swift shim dylib, unlike AppKit (directobjc2).
ADR-028: Engine substrate — Vello + Parley with native text-input slots (hybrid)
Section titled “ADR-028: Engine substrate — Vello + Parley with native text-input slots (hybrid)”Status: Accepted
Context: OQ-014. Upstream GPUI is pre-1.0 with breaking API churn and near-zero documentation (the decisive objection — the requirement was never “stable API” but “stable within a pinned version + usable docs”). Xilem is alpha; Vello is split into vello_gpu/classic/cpu at ~beta quality. Meanwhile the single most expensive part of a from-scratch renderer is the text-input stack: IME composition windows, cursor/selection position reporting, per-OS input protocols (Windows TSF being the notorious one), spell-check, emoji picker.
Decision:
- Rendering substrate: Vello + Parley + winit + AccessKit, with the project’s own HoF layer on top (ADR-008). Versions pinned; vello_cpu serves as the CPU fallback.
- Text input is NOT written from scratch. The heaviest input
concerns (IME, spell-check, selection, emoji picker) are delegated to
the platform through the Native axis slots (ADR-023):
macOS
NSTextView(viaobjc2) or SwiftUI shim; Windows WinUI3TextBoxvia XAML Islands (TSF direct as the hard-mode alternative); Linux GTKGtkTextViewviagtk4-rs. - Only lists, scrolling, and message rendering are implemented on the project’s own engine. The message list stays on our renderer (no native views inside its scroll container).
- macOS native scroll behaviors (rubber-band etc.) come along with the native surface — accepted and welcome, consistent with the 1:1 native-look goal and the Feature axis (ADR-009).
- Prototype order: the native text-input slot contract first — it is the highest-risk integration point; everything else (lists, scroll) is well-trodden GPU-render territory.
Consequences:
- The Engine axis (ADR-009) carries Vello-based rendering; the Native axis (ADR-023) carries input widgets. Engine and Native are separately swappable — a future Engine swap does not force a Native swap and vice versa.
- Doc/stability exposure is concentrated in Vello + Parley, both documented and pinnable; the undocumented-GPUI risk is dropped.
- winit + windowing: on Wayland and WinUI the “who owns the window” question (ADR-023) decides the embedding direction per platform — that analysis moved from open question to implementation detail of the Native modules.
ADR-030: Hit-testing — single-pass; Style tokens — KDL source compiled to rkyv
Section titled “ADR-030: Hit-testing — single-pass; Style tokens — KDL source compiled to rkyv”Status: Accepted
Context: OQ-002 (hit-testing pass structure) and the follow-on style-token format question raised while closing OQ-001/ADR-029 (RON was doubted as a token format — too human-oriented for runtime data).
Decision:
- Hit-testing: single-pass (immediate-mode style, à la egui). The
visual tree and the hit-test tree are built in the same frame pass;
interaction state (
.clicked(), hover) returns inline in the HoF view code. No separate hit-test pass, no added input latency. Rationale: after ADR-028/023, our own hit-testing covers only lists/messages/ scroll (native fields and menus hit-test themselves in the OS); overlays and complex z-order cases are native — the known weaknesses of single-pass do not apply to our domain. - Style token format: KDL source → rkyv binary. Authors write
theme.kdl(human-readable); a compile step producestheme.rkv(rkyv archive) that the runtime mmaps for zero-copy token access. A__pycache__-style cache (.fera-cache/theme.rkv+ source hash) auto-recompiles when the KDL changes. Chain: KDL → AST → typed token tree → rkyv. Token referencing and theme inheritance (dark extends base) are expressed in the KDL layer. rkyv is already the project’s serialization boundary choice (ADR-007) — no new concepts. KDL parser note: thekdlcrate is small (no heavy deps) but in maintenance lull; it is ~2–3k lines and forkable if needed. RON stays where it belongs — Lattice manifests (Rust-typed data).
Consequences:
- The HoF layer’s component functions return interaction state inline; no deferred hit-test resolution step exists to test.
- Theme loading at runtime is an mmap + pointer fixup, not a parse — theme file size stops being a startup cost.
- A second small tool joins the build/cache pipeline (KDL→rkyv
compiler); the
kdlcrate’s maintenance state is a watch-item, with forking as the pre-planned fallback.