Raw capture โ raw/chatto.md
url: https://github.com/chattocorp/chatto title: Chatto author: chattocorp (Hendrik Mans) date_fetched: 2026-07-11 date_published: 2026-03-01
A real-time chat application for teams and communities, built in Go with a SvelteKit frontend, running entirely on NATS/JetStream with no relational database. The project is a monorepo โ cli/ is the Go binary (embedded NATS + HTTP server + ConnectRPC API), apps/frontend/ is the SvelteKit SPA compiled into the binary, proto/ holds the protobuf definitions, and docs/adr/ holds 48 Architecture Decision Records.
cli/internal/ (core domain logic, HTTP server, event framework, ConnectRPC handlers, protobuf bindings, video processing)cli/internal/core/core.go โ the central hub ChattoCorecli/internal/events/publisher.go โ the OCC-only event publishercli/internal/events/projector.go โ projection framework with shared replayapps/frontend/e2e/hmans.de/chatto, Go 1.26.0, ~80 direct dependenciesChatto is a single-binary event-sourced chat server using NATS/JetStream as the sole persistent data store. There is no PostgreSQL, no MongoDB, no Redis โ just NATS. The binary embeds a full NATS server for single-process deployments; for horizontal scaling, multiple Chatto instances connect to an external NATS cluster.
Three storage tiers, all NATS-native:
EVT JetStream stream โ the event-sourcing log. Every durable domain fact (user created, message posted, reaction added, role created, room membership changed, etc.) is appended as a protobuf-encoded event. This is the system's source of truth.
KV Buckets โ latest-value key-value stores for runtime state:
- RUNTIME_STATE โ file-backed, TTL-driven: notifications, push subscriptions, auth tokens, link-preview cache, wrapped application DEK records
- MEMORY_CACHE โ memory-backed, volatile: presence state, leader-election leases
- ENCRYPTION_KEYS โ KMS key-encryption keys, excluded from backups
Object Stores โ binary assets:
- SERVER_ASSETS โ avatars, branding, link previews, message attachments. NATS-backed or S3-backed
- ASSET_CACHE โ optional TTL-based cached image transforms
All writes to EVT use optimistic concurrency control (OCC). The publisher framework (cli/internal/events/publisher.go) offers no non-OCC publish primitive. Every write carries Nats-Expected-Last-Subject-Sequence, guaranteeing per-subject serialized history with no race gaps.
Domain state is never read directly from EVT. Instead, 13 in-memory projections consume the event stream and maintain current state in process memory:
evt.room.>)evt.group.>, evt.layout.>)evt.room.>)evt.room.* events)evt.room.> โ intentionally broad for room-tail OCC)evt.room.>)evt.config.>)evt.user.>)evt.user.*.dek_generated, evt.user.*.user_key_shredded)evt.rbac.>)evt.>)evt.asset.>, legacy evt.room.*.asset_*)Each projection is a Go struct implementing Apply(event, seq) error. At startup, ChattoCore.Run replays evt.> through one shared ordered consumer, decodes each event once, then fans it out to all projectors whose subject filters match. This shared replay avoids duplicate decode work and JetStream metadata parsing.
Read-your-writes is per-process: after a successful publish, the writer waits for the local projector to advance past the published sequence before returning. Cross-process consistency is eventual (sub-millisecond, bounded by NATS Core latency).
events.Publisher provides the write primitives:
Append(ctx, subject, event) โ auto-computes expected seq, publishes with OCCAppendEventually(ctx, subject, event) โ retries OCC conflicts up to 5 times with exponential backoff (for append-only events like messages, memberships)AppendAt(ctx, subject, event, expectedSeq) โ explicit expected sequence (for deterministic replay)AppendAtFilter(ctx, subject, event, filter, expectedFilterSeq) โ OCC against a wildcard subject filter (for cross-aggregate invariants like unique room names)AppendBatch(ctx, entries) โ atomic batch publish for multi-aggregate cascades, using NATS batch protocolConflict handling is explicit: state-replacement mutations (profile updates, config changes) must re-read the projection and recompose; append-only mutations (messages, reactions) can retry the same payload.
Protobuf-first with two surfaces:
ConnectRPC mounted at /api/connect โ ~100+ unary RPCs across five service packages:
- chatto.auth.v1 โ external identity auth
- chatto.discovery.v1 โ unauthenticated server discovery (CORS-wildcard)
- chatto.api.v1 โ integration-oriented API (rooms, messages, users, notifications, etc.)
- chatto.admin.v1 โ administration (server config, RBAC, event log)
- chatto.operator.v1 โ root-equivalent API on a Unix socket only
Realtime WebSocket at /api/realtime โ protobuf binary frames (chatto.realtime.v1.Realtime*), authenticate with bearer token in hello frame or cookie session. Live-only (v1), no replay cursors; missed events recovered via projected ConnectRPC reads.
The authentication model uses session.{hmac} typed runtime credentials (ADR-046). Bearer tokens for cross-origin clients, cookie sessions for same-origin browsers.
Permission-only RBAC (ADR-040) with hierarchical scope resolution (ADR-005): - Server scope โ Room Group scope โ Room scope - Deny wins at more specific scopes - Default roles (owner, admin, moderator, member, everyone) with system-immutable positions - Custom roles with reordering - Direct user permission overrides for exceptions - Owner role is immutable and has an implicit override on all permissions
Each user has a Data Encryption Key (DEK) managed through a KMS (cli/internal/kms/). Message bodies are encrypted with per-user keys. Deleting a user's key shreds all their messages permanently โ satisfying GDPR deletion requirements without touching the immutable event stream (ADR-007).
Multi-instance client (ADR-025): the SvelteKit SPA connects to multiple Chatto servers simultaneously. Server state is managed per-server in the frontend state layer (src/lib/state/). The frontend uses:
- Optimistic UI with scoped provisional patches (ADR-048)
- Service Worker for asset URL resolution (ADR-039) and push notifications
- Svelte 5 runes for reactive state management
- TipTap for the rich-text composer with mentions and emoji autocomplete
- Paraglide for i18n (German locale shipped)
- Virtual scrolling with O(1) scroll position recovery
Optional long-running processes (chatto <unit> or embedded under chatto run):
- chatto exporter โ Prometheus metrics across the NATS cluster
- Voice call reconciler โ elected leader reconciles LiveKit state with durable facts
- Asset cleanup worker โ elected leader deletes physical binaries after AssetDeletedEvent
- Video processing โ best-effort local ffmpeg for attachment transcoding
All elected workers use MEMORY_CACHE KV-based leases for leader election.
Event subjects follow evt.{aggregateType}.{aggregateId}.{eventType}:
- evt.room.{roomId}.message_posted
- evt.room.{roomId}.member_joined
- evt.user.{userId}.profile_updated
- evt.rbac.role.{roleName}.created
- evt.config.{configId}.server_updated
The subject pattern is what makes OCC work โ each aggregate gets its own lane with independent sequence numbering.
Even within EVT, messages use two layers (ADR-011):
- Public message facts (MessagePostedEvent, MessageEditedEvent) โ immutable, visible in timeline
- Private body payloads (MessageBodyEvent) โ retention-controlled, can be securely deleted
This lets the system delete message content (for policy or GDPR) while preserving the immutable conversation metadata. A deliberate departure from textbook event sourcing.
All entities use NanoID with type prefixes (ADR-022): user_, room_, msg_, evt_, role_, grp_, link_, asset_, not_, bn_, call_, srv_, file_, upload_, token_, ext_. Event IDs use NanoID independently of stream sequence numbers (ADR-026).
When a MoveRoomToGroup needs to atomically remove a room from one group and add it to another, Publisher.AppendBatch uses NATS's Nats-Batch-Id protocol โ the entries land contiguously in stream order, so projections observe both changes together (or neither).
ADR-033 initially had each projector running its own OrderedConsumer, but the duplicate delivery cost was significant. ChattoCore.Run now replays evt.> through one consumer, decodes once, and fans out. Each projection still maintains independent status, lag, failure, and wait state. This cuts startup replay work from O(projections ร events) to O(events).
Everything runs in one process. No database to configure. Single binary, single config file, single data directory. The embedded NATS makes this possible.
No SQL, no joins. All reads go through projections. Operators can't run SELECT * FROM messages WHERE... โ they use chatto evt list or the admin event-log API. Analytics and full-text search require dedicated projections or external systems.
Event sourcing is typically associated with financial ledgers and audit systems, not chat. Chatto adopted it to solve a real problem: subject-index RAM growing 1:1 with message count under the old KV-based model. The event-sourcing switch cut subject cardinality from O(messages) to O(aggregates).
Most self-hosted chat apps use PostgreSQL or SQLite. Chatto puts everything in NATS โ messages, users, config, RBAC, sessions, everything. This eliminates a deployment dependency but ties the project irreversibly to NATS's semantics.
Each Chatto process maintains its own in-memory projections. Cross-process consistency is eventual. A read on process A might not yet see a write committed on process B โ but in practice the delay is sub-millisecond. This avoids a single-point-of-failure shared projection service at the cost of eventual consistency.
Exceptionally well-documented for an early-stage project:
- 48 ADRs with dates, context, decisions, and consequences
- 28 FDRs for per-feature design
- 910-line ARCHITECTURE.md with complete NATS resource inventory
- Glossary with canonical naming conventions
- AGENTS.md files at repo, directory, and subdirectory levels for LLM coding agent guidance
- .agents/skills/ directory with 16 skills for the dev team's LLM agents