diff --git a/docs/adr/0002-storage-architecture.md b/docs/adr/0002-storage-architecture.md new file mode 100644 index 00000000..5e06a1c0 --- /dev/null +++ b/docs/adr/0002-storage-architecture.md @@ -0,0 +1,132 @@ +# Storage Architecture + +| Field | Value | +|---|---| +| Status | Proposed | +| Issue | https://github.com/logos-messaging/libchat/issues/112 | +| Discussion | https://github.com/logos-messaging/libchat/discussions/218 | +| Date | 2026-08-25 | + +## Context and Problem + +Conversation types are the unit of change in libchat and the expected cadence is high, plausibly a new type every few weeks. Each arrives with storage requirements of its own: GroupV2 brings peer scores, a consensus signer key, `app_id`, pending invites, and its config on top of MLS group state. Whenever such requirements reach the store contract, a release breaks every store implemented outside this repo and hands each author a migration for state they do not own. + +Issue #112 is the trigger: MLS group state lives in an in-memory `MemoryStorage`, so no conversation survives a restart. The question it forces is not how to persist MLS, but where a type's schema lives, so that shipping one stays a libchat-only change. + +## Decision Drivers + +- **A new type must not move the boundary:** no trait change, no DDL, nothing to do for a store written a year earlier. +- **State is scoped to the protocol that produced it,** so sandboxing or retiring one is mechanical. + +## Architecture + +The app injects one store carrying two independent contracts: a typed `ClientStore` for client-level state and a `NamespacedKvStore` substrate for everything a conversation type owns. + +`ClientStore` names the client-level boundary rather than one fixed trait: the conversation list and identity today, more traits as the client's domains grow. It is a typed contract, not a schema mandate, so a store may back it with rows or with its own key-value layout. + +Everything above the substrate is libchat's. A conversation gets a `KvStore`, the substrate's verbs with its protocol's namespace already bound; a type keeps its typed accessors and its adapters for foreign storage traits in one module, the typed layer in the diagram. `ClientStore` is to the client what that layer is to a conversation type; the difference is that the store implements one and libchat the other. + +```mermaid +flowchart TB + App["app"] + Client["client
conversation list, identity"] + Types["conversation types
GroupV1 · DirectV1 · GroupV2 · InboxV2"] + + subgraph Typed["typed layer"] + KV["KvStore
(key, value)"] + end + + subgraph Store["injected store: two independent contracts"] + CS["ClientStore
client-level state, typed"] + NKV["NamespacedKvStore
(namespace, key, value)"] + end + + App --> Client + Client --> Types + Client -- "typed calls" --> CS + Types -- "typed calls" --> Typed + KV -- "namespace + key" --> NKV +``` + +## Decisions + +1. **The injected substrate is five verbs over bytes, singly or in a transaction.** `NamespacedKvStore` takes the namespace on every call; bare verbs are autocommit singles, and `begin()` opens a transaction carrying the same verbs for anything larger. The stock store implements it as `CREATE TABLE kv (ns TEXT, key BLOB, value BLOB, PRIMARY KEY (ns, key))` beside whatever it uses for `ClientStore`; the in-memory store is a map per namespace. Neither contract knows about the other, so a store can implement one and reuse a stock implementation of the other. + + ```rust + /// `&self` throughout because OpenMLS requires it; implementations may use interior mutability. + trait NamespacedKvStore { + type Error: std::error::Error; + + fn get(&self, ns: Namespace, key: &[u8]) -> Result>, Self::Error>; + fn put(&self, ns: Namespace, key: &[u8], value: &[u8]) -> Result<(), Self::Error>; + fn delete(&self, ns: Namespace, key: &[u8]) -> Result<(), Self::Error>; + fn scan_prefix(&self, ns: Namespace, prefix: &[u8]) -> Result, Vec)>, Self::Error>; + fn delete_prefix(&self, ns: Namespace, prefix: &[u8]) -> Result<(), Self::Error>; + + /// One atomic unit: `commit` lands everything, drop without commit rolls back. + fn begin(&self) -> Result + '_>, Self::Error>; + } + + /// The same five verbs, plus `fn commit(self: Box)`; reads inside see the unit's own writes. + trait KvTx { /* get, put, delete, scan_prefix, delete_prefix, commit */ } + ``` + +2. **A namespace is the protocol that owns the state, as a closed enum:** `Namespace::{GroupV1, DirectV1, GroupV2, InboxV2}`, gaining a variant when a protocol ships. Uniqueness becomes a property of the type rather than a convention, protocol names already carry their version, and retiring one is `delete_prefix` over the whole namespace. + +3. **A conversation addresses storage through a `KvStore` handed to it, never through the substrate.** A `KvStore` is the same five verbs with one namespace already bound (`tx.scope(ns)` builds it), so a type composes whatever key layout it wants below its namespace and cannot reach another protocol's state. Scopes are per operation: the entry point opens the transaction, the scope over it is created at the one site that already branches on the stored kind, and a conversation receives a fresh one per call, so a write outside the open unit is unrepresentable. `ServiceContext` does not carry the substrate; cross-type needs are met by services, never by another protocol's scope. + + ```rust + // core.rs, the one site that branches on the stored kind + let convo: Box> = match record.kind { + ConversationKind::GroupV1 => Box::new(GroupV1Convo::load( + cx, tx.scope(Namespace::GroupV1), record.local_convo_id)?), + ConversationKind::GroupV2 => Box::new(GroupV2Convo::load( + cx, tx.scope(Namespace::GroupV2), record.local_convo_id)?), + ConversationKind::Unknown(kind) => return Err(ChatError::UnsupportedConvoType(kind)), + }; + + // conversation/group_v1.rs, the type shapes every key below its namespace + let group_id = GroupId::from_slice(&hex::decode(&convo_id)?); + let mls = MlsAdapter::new(scope, cx.key_packages()); + let mls_group = MlsGroup::load(&mls, &group_id)?; + ``` + + Conversation logic never composes a key inline; keys stay behind the type's accessors. Keeping conversations of one type apart is the type's own key discipline; the scope enforces the protocol boundary and nothing finer. + +4. **Code shared between types is written once and constructed with the owner's scope.** A component several types reuse takes its `KvStore` at construction, so one implementation lands state in whichever namespace owns the conversation. The MLS `StorageProvider` adapter is today's case: GroupV1 and GroupV2 groups get identical key shapes from the same code and still land in their own namespaces. Keys lead on the group id, so a group is one prefix to load and one to purge. + + ``` + ns GroupV1 mls//tree, mls//context, mls//epoch///enc_keys + ns GroupV2 mls//... same shapes, same code, different scope + convo//config, convo//peer_scores/ + ns InboxV2 key_package/ minted once, consumed on Welcome + ``` + + A cross-type entry stays single-copy with the type that mints it, offered to the rest as a service, never as a scope. Today that is the key package: InboxV2 mints it, a Welcome for a group of any type consumes it unless it is last resort, and `cx.key_packages()` is that service, implemented over InboxV2's scope. Nothing else crosses types: the signature keypair never reaches the provider (libchat signs through its own `IdentityProvider`), and standalone encryption keypairs are group state. + +5. **One transaction per mutating core entry point, committed before anything is published,** so a crash loses at worst an unsent message, never sent-but-forgotten state. + + ```rust + // core.rs, handle_payload, welcome path: one welcome, one transaction + let tx = self.storage.begin()?; + let convo = GroupV1Convo::new_from_welcome( + &mut self.services, tx.scope(Namespace::GroupV1), welcome)?; + tx.commit()?; // only then publish + + // conversation/group_v1.rs, new_from_welcome: every write lands in the caller's transaction + let mls = MlsAdapter::new(scope, cx.key_packages()); + let mls_group = StagedWelcome::build_from_welcome(&mls, &Self::mls_join_config(), welcome)? + .build()? // consumes key_package/ in ns InboxV2 + .into_group(&mls)?; // writes mls//tree, context, epoch keys in ns GroupV1 + ``` + + A crash between any two of those writes is a group that can never open; the transaction lives on the substrate so one unit can span namespaces. + +6. **App features are the app's concern.** libchat persists protocol and client state; whatever the app builds on top, the app stores. + +## Consequences + +Shipping a conversation type is a namespace variant plus the type's own storage module, all inside libchat; an app on the stock store bumps the dependency and gains rows in the existing `kv` table. The price is that type-owned state is opaque to the store: listing is scan-and-decode, inspection sees blobs, and schema discipline moves into serialization conventions. + +`ClientStore` changes remain breaking for stores, and that is the bet: types keep arriving, while a conversation list and one identity are close to complete. If the bet proves wrong, folding client state into a namespace converges this design onto a pure substrate. +