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