# Chat storage — spec

*Where 1:1 chat history lives on each device, and why it is a separate encrypted database from the inventory.*

## Problem

Chat history was persisted as a **JSON blob per conversation in the OS keystore**
(`flutter_secure_storage`), via `MessageStore`. That is the wrong tool once a
conversation is used:

- **Read-modify-write on every message.** Each incoming/sent message read the
  whole conversation JSON, decoded it, checked duplicates in O(n), re-encoded
  the whole list, and wrote it back — O(n) per message, O(n²) over a
  conversation.
- **Silent data loss.** A hard cap of 200 messages per peer dropped the oldest
  without warning. It was a recent-cache, not storage.
- **Keystore misuse.** The OS keystore (Android `EncryptedSharedPreferences`,
  iOS Keychain) is for small secrets (keys, tokens), not growing blobs.
- **Manual index.** Peers were tracked in a `\n`-joined string because the
  keystore can't enumerate keys; the inbox (`conversations()`) then read every
  peer's full blob just to get its last message.

## Decision

Move chat history to an **encrypted Drift/SQLCipher database**, in its own file
(`tane_chat.sqlite`) — `ChatDatabase`, separate from the inventory
`AppDatabase` (`tane_inventory.sqlite`). Same SQLCipher key (from the OS
keystore), so "no plaintext at rest" holds.

### Why a *separate* database, not a new table in `AppDatabase`

Chat is **not inventory**. It is an ephemeral, per-device cache of what the
relays delivered; it never enters an inventory snapshot, a `.tane` backup,
or the device-to-device sync. Keeping it out of `AppDatabase` means:

- It carries **no CRDT metadata** (no HLC `updatedAt`, tombstones, `lastAuthor`,
  `schemaRowVersion`) — those are meaningless for a network cache.
- It stays out of the inventory's **versioned migration series** (no `v9` bump,
  no `drift_schema_v9.json`, no new `migration_test` case). `ChatDatabase` is
  `schemaVersion = 1`, created fresh; there is nothing to migrate.
- It never trips the inventory **sync stream** (`AppDatabase.tableUpdates()`
  feeds `SyncService`); a message arriving can't spuriously republish the
  inventory.

### Schema — `Messages`

One flat, indexed table (see `apps/app_seeds/lib/db/chat_database.dart`):

| column          | notes                                              |
|-----------------|----------------------------------------------------|
| `id`            | autoincrement — local rowid, also the stable tie-break for equal timestamps |
| `accountScope`  | namespaces rows per social identity (was the keystore key prefix) |
| `peerPubkey`    | the conversation                                    |
| `fromPubkey`    | sender (self vs peer → bubble side)                 |
| `body`          | message text                                        |
| `sentAt`        | ms since epoch (NIP-17 timestamp) — ordering + read mark |

- **Index** `(accountScope, peerPubkey, sentAt)` serves history and the inbox.
- **Unique key** `(accountScope, peerPubkey, fromPubkey, sentAt, body)` makes
  de-duplication a DB invariant: a relay re-delivers the same gift wrap on every
  (re)subscribe, and an identical row simply isn't inserted twice.

### Behaviour vs. the old store

`MessageStore` keeps its public API (`history` / `append` / `conversations`), so
`MessagesCubit`, `InboxService`, `UnreadService` and the inbox screen are
unchanged. What changes:

- **`append`** is a single indexed insert (dedup-checked in a transaction),
  not a whole-conversation rewrite. Returns `true` only when newly stored.
- **`history`** is uncapped, ordered by `sentAt` then `id` (stable insertion
  tie-break for equal timestamps).
- **`conversations`** is one indexed scan; the newest message per peer is picked
  in Dart (peer counts are small).

## Not migrating old data

Pre-release: there are no users, so the old keystore blobs are **not** migrated.
The keystore keys (`tane.social.chat.*`, `tane.social.chats`) are simply
abandoned. If that changes before release, a one-shot import can be added.

## Related, deliberately not changed

The same keystore-blob anti-pattern exists, at trivial scale, in `OfferOutbox`
(pending lot ids), `TrustReferents` (WoT referent set) and `SocialSettings`
(relay URLs). These are small, bounded, rarely-written lists — the keystore is
acceptable there. Only chat grows with everyday use, so only chat moved. If any
of those grows unexpectedly, the same Drift treatment applies.
