Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions docs/adr/0002-storage-architecture.md
Original file line number Diff line number Diff line change
@@ -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["<b>app</b>"]
Client["<b>client</b><br/>conversation list, identity"]
Types["<b>conversation types</b><br/>GroupV1 · DirectV1 · GroupV2 · InboxV2"]

subgraph Typed["typed layer"]
KV["<b>KvStore</b><br/>(key, value)"]
end

subgraph Store["injected store: two independent contracts"]
CS["<b>ClientStore</b><br/>client-level state, typed"]
NKV["<b>NamespacedKvStore</b><br/>(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<Option<Vec<u8>>, 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<(Vec<u8>, Vec<u8>)>, 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<Box<dyn KvTx<Error = Self::Error> + '_>, Self::Error>;
}

/// The same five verbs, plus `fn commit(self: Box<Self>)`; 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<dyn Convo<S>> = 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/<gid>/tree, mls/<gid>/context, mls/<gid>/epoch/<epoch>/<leaf>/enc_keys
ns GroupV2 mls/<gid>/... same shapes, same code, different scope
convo/<convo_id>/config, convo/<convo_id>/peer_scores/<ident>
ns InboxV2 key_package/<hash_ref> 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/<hash_ref> in ns InboxV2
.into_group(&mls)?; // writes mls/<gid>/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.

Loading