Skip to content

Latest commit

 

History

History
401 lines (336 loc) · 22.6 KB

architecture.md

File metadata and controls

401 lines (336 loc) · 22.6 KB

Architecture

Status: implemented pre-alpha architecture

What this server is

The server is a small, durable chat system whose first public protocol is IRC. It is not a traditional connection-centric IRC daemon with a bouncer bolted on. IRC clients are adapters onto an account-, device-, conversation-, and event-centric core.

The intended installation is a small community on one standalone server.

Implementation sequencing and the boundaries shared by onboarding, moderation, automation, and external content are maintained in the consolidated implementation plan.

Core model

  • Account is a durable authenticated identity with an immutable human, bot, or agent kind asserted by the server. Accounts are optional for public servers and public channels.
  • Anonymous identity is a pseudonymous session or renewable bearer identity. It does not require an email address, phone number, or stable public host.
  • Device is one installation or client credential belonging to an account. Devices have independent delivery checkpoints.
  • Connection is a disposable transport session. Reconnecting must not create a new person or silently move another device's cursor.
  • Conversation is a channel or direct-message thread.
  • Event is an immutable, uniquely identified item in a conversation log. Messages and notices are events; joins, parts, topic changes, edits, and redactions can use the same envelope later.

Internally, every accepted event receives both an opaque global event ID and a monotonic offset in its conversation. IRC exposes the event ID as msgid; it does not expose the offset. A small sender-presentation snapshot is stored with each message so replay shows the nickname and username recipients originally saw; the stable principal remains authoritative for permissions.

Current avatar and nickname-color presentation is separate account state. It is looked up when a capable client renders a sender, so changing it updates old history without rewriting retained events. Traditional IRC adapters continue to project the stored nickname and message text only.

Cursor semantics

There are two durable cursors and one transient watermark:

State Scope Advanced by Meaning
Read marker account + conversation an explicit client read action The furthest event the human is known to have read
Delivery checkpoint account + installation + conversation an acknowledgement or resumable-session checkpoint The furthest event this installation can safely resume after
Send watermark connection + conversation the server writer The furthest event queued on this socket; never treated as read

All cursor movements are monotonic. A cursor names an actual event in the same conversation, so a client cannot skip ahead with a timestamp from another room.

Read state is shared across an account because that is the behavior people expect across phone, terminal, and web clients. Delivery state is per installation so one active client cannot make another installation miss messages. Merely writing bytes to TCP never advances either durable cursor.

Direct-message identity

A durable direct conversation is identified by one unordered pair of stable local account IDs. It is not identified by either participant's current nickname, connection, or installation. Resolving the pair in either direction returns the same conversation and sequence, and every history or cursor operation first derives that pair from the authenticated requester. A message ID from a different pair is rejected as an invalid history anchor.

PRIVMSG Nick remains compatible with ordinary clients. When both endpoints are authenticated, the server commits the event to their account-pair conversation and fans it out to all registered connections for both accounts. When either endpoint is anonymous, delivery is intentionally live-only: the nickname routes to one current connection and no private history is created. Thus a recycled nickname can neither identify nor recover another person's retained messages.

NOTICE uses the same channel, live-anonymous, and durable account-pair routes, but its event type survives storage and replay. The adapter never generates an automatic error or away response to a notice. This distinction is represented in event content rather than guessed from presentation text.

The IRC adapter also accepts the experimental @account target for authenticated offline addressing and stable history/cursor commands. Account names are local and case-insensitive in this standalone phase. This syntax is documented in direct messages; it is not presented as an IRCv3 standard.

IRC compatibility

Modern clients use IRCv3 draft/chathistory, msgid, server-time, and draft/read-marker. The server synchronizes MARKREAD updates between connections on the same account.

Clients may also negotiate IRCv3 draft/multiline with its advertised 32-component and 16-KiB limits. Telex validates the complete client batch before committing one event, reconstructs it before exposing it through the shared model, and projects readable ordinary lines to clients that do not support batches. The exact transport, replay, and web-review contract is documented in multiline messages.

Authenticated human clients can negotiate the experimental, versioned draft/device-resume-0.2 adapter. After SASL they bind an opaque installation token that selects only a delivery checkpoint; the token is not a login credential. Paging advances only after an explicit MARKDELIVERED acknowledgement. The protocol is documented separately in device resume.

Legacy clients cannot acknowledge delivery or reliably report what is visible. For them, JOIN triggers a configurable bounded overlap of recent retained messages, preferring a possible duplicate over a silent gap. Negotiating chat history disables that fallback. It never advances a cursor, and negotiated message IDs and server timestamps make deduplication and presentation less ambiguous where the client supports them. The complete behavior is documented in legacy client replay. If several clients reuse one saved installation token, they also reuse one delivery checkpoint.

The IRCv3 persistence specs are still marked as drafts, so their wire names and conformance behavior are isolated in the IRC adapter rather than leaking into the domain model.

The account-registration draft is intentionally not implemented yet. Its current text discourages production deployment, and its REGISTER command has no field for the invitation bearer token required by private admission. Overloading email or follow-up VERIFY would be incompatible and would force the server to retain pending credentials. People instead redeem invitations on the native onboarding endpoint and then connect with ordinary SASL clients.

Relevant specifications:

Standalone storage

SQLite in WAL mode is the default. It is enough for the target scale, keeps deployment to one process, supports transactional cursor checks, and gives operators familiar backup and inspection tools.

Current channel topics live in a channel-owned state table rather than the chat event log, so history paging and installation cursors remain message-oriented. Topic text, a bounded setter presentation snapshot, and its timestamp survive an empty room and process restart. Clearing a topic removes that state. Authorization never depends on the presentation snapshot.

The storage boundary exposes domain operations rather than generic SQL:

  • append an event and allocate its conversation offset;
  • advance a read marker if the referenced event is newer;
  • advance an installation checkpoint if the referenced event is newer;
  • page before or after an event ID;
  • apply retention without turning a missing old event into a cursor reset.

Retention prunes message bodies but keeps small event-envelope tombstones. This preserves stable message IDs, conversation ordering, and cursor anchors without retaining expired content. SQLite secure_delete is enabled before updates so ordinary database pages are scrubbed. WAL files, filesystem snapshots, and existing backups require separate checkpoint and lifecycle procedures; logical retention is not represented as guaranteed forensic erasure.

The agreed policy uses a 90-day server channel default with bounded owner/operator overrides, including live-only and longer finite rooms. The initial private deployment expects its general room to use a shorter override of about 30 days and an annual planning room may use roughly 400 days. Durable account-pair DMs use one operator-configured 180-day server default rather than conflicting participant preferences. Each accepted message captures its expiration so a later policy edit cannot silently lengthen already-posted content. The complete disclosure and change contract is in message retention.

A dedicated bounded worker thread owns the SQLite connection and all runtime writes, so async connection tasks neither block an executor nor share a connection behind a mutex. It resolves case-folded channel names to durable conversation IDs and owns sequence allocation. Reads may use separate SQLite connections later. Message bodies and sensitive metadata never appear in normal logs.

Protocol and transports

The server runs one plaintext or TLS IRC listener and can bind a separate IRC-over-WebSocket address. Plaintext is restricted to loopback by default; a non-loopback address on either transport requires an explicit development override and suppresses SASL there. TLS configuration applies to both addresses, loads and validates its certificate chain and matching key before binding, bounds incomplete handshakes, and permits password authentication remotely. Certificate hot reload remains planned. Capability negotiation is foundational rather than optional parsing.

The implemented compatibility set is version-aware CAP 301/302 with cap-notify, SASL PLAIN over TLS or on loopback, message tags, batch, server time, message IDs, echo message, complete draft chat-history paging and target discovery, read markers, account tags, extended joins, away notification, multi-prefix, userhost-in-names, durable type-preserving NOTICE, durable account-pair direct messages, recipient-owned DM policy with opaque pre-persistence rejection, and the experimental device-resume adapter. account-notify is deliberately absent until Telex has a reviewed use for post-registration account transitions; current sessions authenticate once and cannot switch accounts. Labeled command correlation composes zero-, one-, and multi-line replies without labeling unrelated traffic, while standard replies preserve established numerics; the exact boundary is in IRCv3 correlated responses. Identity and presence events are rendered per recipient, as detailed in IRCv3 identity and presence. Common established-client discovery and presence commands are also implemented: WHO, account-aware WHOX, WHOIS, LIST, AWAY, ISON, USERHOST, MOTD, LUSERS, and durable TOPIC with 331/332/333 replies. These surfaces always publish anon.invalid, use the WHOX non-address placeholder rather than disclosing an IP, hide an invite-only channel from non-members, and do not support wildcard WHO user enumeration. Exact-nickname WHOIS exposes the authenticated account through numeric 330, as expected by account-aware IRC clients, without exposing a device ID. SASL EXTERNAL remains planned.

IRC over WebSocket uses the standardized text.ircv3.net and binary.ircv3.net subprotocols, preserves client preference, and maps exactly one unterminated IRC line to each WebSocket message. The handshake and message limits are bounded. Browser origins are denied unless exactly allowlisted; native clients without an Origin header remain usable. This makes a browser client possible without a gateway. The implemented native sync API adds exact acknowledgements, private control results, and a structured extension boundary while using the same domain operations and event log rather than becoming a second chat system. M12 automation and provider-hosted external-content references now extend that same boundary. Direct DCC negotiation remains live and connection-scoped; registered XDCC providers receive only ephemeral transfer-control frames, while clients exchange file bytes outside the daemon. Deployment details are in IRC over WebSocket.

The full IRC terminal and browser clients share one dependency-free protocol, session, model, and durable-resume coordinator. The native terminal reuses the model and storage contracts through the structured M11 session. A delivery acknowledgement is emitted only after the terminal's flushed atomic file replacement or a committed IndexedDB transaction. The browser requires a persistent-storage grant before enabling resume. Read markers advance from terminal output or actual visible browser scroll content, not merely because a conversation is selected.

Privacy defaults

  • Never disclose a client IP address or reverse DNS name to ordinary users.
  • Do not persist raw client IP addresses. Abuse controls use short-lived, rotating keyed hashes when an address-derived key is unavoidable.
  • Redact addresses, credentials, tokens, and message bodies from logs.
  • Permit anonymous use when the operator enables public access; do not require profile fields or an external identity provider.
  • Use TLS for non-loopback client traffic.
  • Store each human account password with Argon2id. A password change invalidates every old login, including optional remembered-device bearers, and disconnects live sessions; users intentionally carry one password across clients.
  • Give automated deployments random, labeled, independently revocable app keys with only SHA-256 digests retained server-side.
  • Authorize retained direct history by stable account pair, never by a current or previously used nickname.
  • Make history retention configurable per server and conversation, with a finite privacy-oriented default.

This is not end-to-end encryption. The server operator can read content while the server is running and from retained state or backups. The UI and documentation must say this plainly.

Authentication and authorization

Public connections may remain anonymous. Durable cross-client state requires a local account.

Each human account has a case-insensitive name, no email or profile requirement, and one Argon2id password verifier. IRC clients authenticate with the bare account name through SASL PLAIN over TLS or on loopback. Password verification runs in bounded blocking work with a dummy verification for unknown accounts, and failures do not reveal whether an account exists. Account-wide read markers are synchronized between authenticated connections and survive restart.

An operator can require authentication before connection registration. With the default operator-only account-registration mode, this is the smallest authenticated-only server profile. A client that otherwise completed registration receives FAIL * ACCOUNT_REQUIRED and can still authenticate before the bounded registration deadline. Configuration fails closed if this policy is combined with a remote plaintext listener, where password authentication is deliberately unavailable.

The invitation domain stores only Argon2id hashes of bounded random bearer secrets and atomically creates a human account and password verifier. Issue and revoke capabilities are separate, while an offline infrastructure command exists for bootstrap. No issuer-to-redeemer edge is stored or audited. The implemented native API adds explicit operator/invite registration modes, account-attributed steward actions, bounded request and hashing work, transient keyed source throttles, generic redemption failures, and remote-TLS enforcement. Its same-origin onboarding page loads no external content, uses no session or cookie authority, and clears browser form secrets after use. See invitation core and native control API v1.

Human installation identity is deliberately separate from login authority. After account-password authentication, a supporting client creates or binds a random dr1... token stored server-side only as a SHA-256 digest. The token selects one delivery checkpoint and cannot authenticate. Password changes retain those checkpoints while invalidating the old verifier and disconnecting live sessions.

The web client can explicitly opt one installation into a separate random rd1... remembered-login bearer, also retained server-side only as a digest. It authenticates the human as account/<installation-uuid> but must still bind the matching dr1... cursor. It cannot create another bearer or rotate the account password. Individual revocation preserves the cursor; password change revokes all remembered bearers. Procedures and trust boundaries are in remembered devices and account password changes.

Automated accounts have no account-wide password. Provisioning returns one server-generated ak1... app key exactly once; its account/key-id authentication ID names that key. Labeled keys are independently addable and revocable, and only their SHA-256 digests are stored. This preserves deployment granularity without making ordinary users manage per-device secrets.

An optional initial-channel subscription is also account state, not connection state or channel authority. New human accounts receive it atomically at creation, every authenticated device autojoins it, and a successful PART deletes it so future connections respect the user's choice. Existing accounts are never silently backfilled by a configuration change. See initial channel.

Private channels express membership in terms of stable account IDs, not nicknames or host masks. They are created private from inception with one protected owner; existing public channels cannot be relabeled after clients may have retained their history. Admission carries one revision-consistent durable policy snapshot into runtime state. JOIN and live policy publication share one gate, while compare-and-set revisions prevent concurrent writers from silently overwriting each other. A live private-member removal reconciles all connected devices and sends standard KICK messages before returning. The CLI administration path still requires a stopped listener; the running surface is an infrastructure capability for embedding and future control-plane adapters. Owner assignment and transfer are explicit atomic operations. Successful grant, role, owner, account lifecycle, and channel-initialization mutations write metadata-only records with 30-day retention in the same transaction. Account disable refuses to strand an owned channel, shares a read/write gate with SASL and authenticated command dispatch, and disconnects all sessions after the durable update. Channel access mode is fixed at creation; there is no unsafe public/private relabel path. In-band account-backed role, invite, kick, ban, and member-limit checks use the same revisioned authority snapshot. Details are in private channels and authority audit, with IRC presentation in channel moderation.

Familiar IRC modes are translated at the adapter boundary where possible. The standard invite-only and ban denials are compatible presentation, not a claim that transient nickname or host masks are authoritative. The +k adapter is an optional shared-key condition over that base policy: public +k admits a server-admitted key holder without creating a durable role, while +ik requires both the account-backed membership and the key. It never bypasses server admission, bans, or limits. Argon2 verification is bounded and performed outside the authority mutation lock, then checked against the same policy revision under that lock before JOIN commits. The verifier and IRC disclosure contract are in shared-key channels.

Links and media

Plain URLs work everywhere immediately. Rich clients may render links and image URLs locally.

Server-fetched previews are absent because they leak reading activity and create an SSRF surface. The IRC daemon does not host uploads or proxy downloads. The implemented structured-reference adapter catalogues files held by an external provider and delivers a short-lived, one-use download capability only to the requester. Legacy IRC clients receive useful URL, filename, size, digest, and warning text. The complete contract is external content references.

Delivery plan

The local foundation is complete through account/installation identity, cursor-safe history, IRC/WebSocket adapters, invite onboarding, familiar moderation, DM privacy, configurable retention, content epochs/reset with cooperative reference-client cleanup, the native integration gateway, grant-enforced disclosed automation participation with atomic operating budgets, loop tripwires, cooldown and moderator pause, the narrow MCP adapter with deterministic and model-backed downstream examples, and provider-hosted external-content exchange.

The remaining release work is validation and deployment:

  1. exercise the documented desktop/mobile/terminal IRC compatibility matrix;
  2. review the production topology and checked configuration profile;
  3. complete standalone deployment fault checks and burn-in as an independent acceptance milestone rather than a code gate.

The checkboxes, release boundaries, and accepted decisions live in the roadmap.