eleven / messenger Public

docs: sealed search index β€” the plan for message search at business scale (#914)

Design doc for the web search upgrade before large spaces make #586's
accepted-for-now shape hurt: today's per-session decrypt-everything scan is
fine to ~100-200k rows per device, linear beyond. The plan: persist the
derived index as AES-GCM-sealed term shards in IndexedDB under a
device-local non-extractable key β€” store.js's never-plaintext-at-rest rule
kept β€” fed from the decrypt paths that already exist (runBackfill's onPage
hook was reserved for exactly this), queried via postings that are hints
re-verified against the ciphertext store, so edits/deletes need no
synchronous index maintenance. Aligns web on native's word-prefix semantics
(fixing a real web/iOS mismatch), names the build triggers, and records the
rejections (server-side/searchable encryption permanently; trigram index;
plaintext web index). Plus a pointer from search.js's revisit note.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 948bc70892af95b427aa19e9bd007ecb011cc7d4 parent 0ef936d
2 files changed, +164 −1
  • docs/sealed-search-index.md +161 −0
  • web/static/search.js +3 −1
diff --git a/docs/sealed-search-index.md b/docs/sealed-search-index.md
new file mode 100644
index 0000000..3580c0f
--- /dev/null
+++ b/docs/sealed-search-index.md
@@ -0,0 +1,161 @@
+# πŸ€– Sealed search index β€” plan for message search at business scale
+
+**Status: designed 2026-07-14, not built.** This documents the agreed upgrade
+path for client-side message search before large (Business-tier, ~1 TB
+allowance) spaces make today's design hurt. Nothing here is urgent yet β€” the
+triggers below say when to build it. Companion decisions this plan leans on:
+the storage/durability ladder (`tier-economics.md Β§ Durability per tier`) and
+the R2 attachment offload (#550), which keeps media out of the search path
+entirely.
+
+## Where search stands today
+
+Search is **local on every platform** β€” the query never leaves the device,
+and the server never gains a search surface. That's a consequence of the E2EE
+rule, not a feature toggle, and this plan does not bend it.
+
+- **Web** (#586 + the #228 "time machine"): the local store (`store.js`)
+ holds raw **ciphertext** rows in IndexedDB; `search.js`'s `SearchIndex`
+ decrypts each thread **once per session into an in-memory index** (never
+ persisted), and queries are substring scans over it. "Search earlier
+ messages" walks incomplete threads' history into the store (`runBackfill`)
+ and streams new hits in. The design's own comments accept the limits and
+ say "revisit if a real space ever makes this hurt".
+- **iOS / Mac**: the App-Group `MessageStore` keeps **decrypted payloads** in
+ SQLite with an **FTS5** twin table (`messages_fts`); `MessageStore.search`
+ is a `MATCH` join, newest-first, limit 50. At-rest protection is the OS's
+ (file-level Data Protection), which is the platform-normal posture β€”
+ Signal and WhatsApp do the same.
+
+Two honest asymmetries fall out of that, worth naming because this plan
+resolves one and keeps the other:
+
+1. **Semantics differ**: web matches *substrings* (`matchText` uses
+ `includes`), native matches FTS5 *word/prefix terms*. Searching "ana"
+ finds "banana" on web and not on iOS. This plan aligns both on
+ word-prefix (below).
+2. **At-rest posture differs**: native stores plaintext under OS file
+ encryption; web deliberately stores only ciphertext. This plan **keeps**
+ web's stricter posture β€” that's what "sealed" means.
+
+## Why web search will hurt, and when
+
+The in-memory index costs one full-store decrypt per tab session plus RAM
+proportional to the whole corpus's text. Media never enters the path (the
+store caches message rows only; attachment bytes fetch on demand), so the
+problem scales with **history length, not storage bytes**:
+
+| Space | Rows on device | First search (est.) | Index RAM (est.) |
+|---|---|---|---|
+| Family, years | ~50k | ~1–2 s | ~10 MB |
+| Community, years | ~300k | ~10 s | ~50 MB |
+| Business, a decade | ~2M | minutes | 300+ MB |
+
+A phone browser will evict or kill the tab well before the last row. The
+scan is fine to roughly **100–200k rows per device**; beyond that every
+session pays the full decrypt again.
+
+**Triggers to build this** (client-side heuristics, no analytics β€” measure
+locally and log to console): a device's store crosses ~150k rows, or a
+measured first-search ensure pass exceeds ~3 s. Either observed in a real
+space = schedule the work.
+
+## Design: a sealed, disposable index beside the ciphertext store
+
+One sentence: **persist the derived index the session index already builds,
+encrypted at rest under a device-local key, and query it via postings so a
+search decrypts a few shards and a handful of candidate rows instead of the
+whole store.**
+
+### Invariants (the non-negotiables)
+
+- **Never plaintext at rest in web storage.** Index shards are AES-GCM
+ ciphertext in IndexedDB, sealed under a **device-local, non-extractable
+ WebCrypto key** stored alongside (structured-clone of a `CryptoKey`).
+ `store.js`'s ciphertext-only rule survives intact.
+- **The index is a cache, never truth.** It is derived from the ciphertext
+ store, deletable at any time, rebuilt lazily from the store. Schema-version
+ mismatch β†’ drop and rebuild. Sign-out β†’ delete with everything else.
+- **Postings are hints.** Every hit re-decrypts its row from the store and
+ re-verifies the match before display β€” so stale postings from edits,
+ deletes, and tombstones fall out silently rather than needing synchronous
+ index maintenance.
+- **Per-device coverage, honestly stated.** The index covers what this
+ device's store holds; the existing backfill narration ("searching back to
+ 2024…") already sets that expectation and stays.
+- **No dependencies, no build step.** Tokenization via the built-in
+ `Intl.Segmenter`; everything else is plain ES modules. (Running the
+ indexer in a module Worker to keep the UI responsive is an implementation
+ freedom, not a requirement.)
+
+### Shape
+
+- **Stores** (new object stores in the existing `lchat-store` DB):
+ `idx-shards` (sealed term-shard blobs), `idx-meta` (per-thread indexed
+ interval + schema version), `idx-key` (the CryptoKey).
+- **Tokenization**: `Intl.Segmenter` word segmentation, lowercased; message
+ text plus sender name. **Query semantics become word-prefix AND** β€” each
+ query term must prefix-match an indexed term. This matches native FTS5,
+ fixing asymmetry #1, and is what every mainstream chat app does. (The
+ substring-parity alternative β€” a trigram index β€” was rejected: ~3Γ— the
+ size for a semantics nobody expects.)
+- **Shards**: terms bucketed by normalized prefix, target ~128–512 KB per
+ shard sealed; postings are `(threadId, messageId)` pairs, delta-encoded.
+ Sizing: ~2M messages Γ— ~12 tokens β‰ˆ 24M postings β‰ˆ well under 100 MB
+ sealed β€” comfortable IndexedDB territory.
+- **Write path β€” piggyback on decryption that already happens**: live
+ receive, thread render, `SearchIndex.ensureThread`, and `runBackfill`'s
+ `onPage` hook (whose docstring already reserves it "for incremental
+ indexing"). A buffered writer batches term→posting appends and
+ read-modify-writes sealed shards. Per-thread coverage is tracked as an
+ **interval** (`[minIndexed, maxIndexed]`) rather than the session index's
+ seen-`Set` β€” backfill extends it downward, live traffic upward. (Same
+ known gap-above caveat as the store's backfill watermark; same acceptance.)
+- **Query path**: segment the query β†’ decrypt the few shards its term
+ prefixes land in β†’ intersect postings (AND) β†’ decrypt candidate rows only,
+ newest-first, limit 50 β†’ verify with the real matcher β†’ render through the
+ existing `#msg-results` scaffold and jump. First search on a warm index:
+ milliseconds of shard decrypt plus tens of row decrypts.
+- **Compaction**: lazy and occasional β€” rewrite shards dropping postings the
+ verify step has seen dead (deleted/edited/left threads). Nothing correct
+ depends on it; it only reclaims space, because hints are re-verified.
+
+### What sealing buys, and what it doesn't
+
+Sealing keeps derived plaintext (a term list is readable content!) out of
+casual reach: disk images, sync'd browser-profile backups, and forensic
+strolls through IndexedDB see only ciphertext β€” the same at-rest level as
+the message store beside it. It does **not** defend against an attacker who
+controls the same origin at runtime (XSS) or who lifts the whole profile and
+runs the browser β€” but neither does anything else in the client: the user's
+private key and wrapped thread keys live in the same origin storage. The
+sealed index adds **no new exposure class**; that's the bar it has to meet,
+and the reason a plaintext web index (native-style) was rejected β€” browser
+storage lacks the OS file-protection story App-Group SQLite gets.
+
+## Phases
+
+1. **P0 β€” semantics + measurement** (small, anytime): align web `matchText`
+ on word-prefix AND (shared test vectors web ↔ native so the tokenizer/
+ match contract is pinned the way event vectors pin folds), and add the
+ local first-search timing log that arms the trigger.
+2. **P1 β€” the sealed index** (the feature): stores, writer, query path,
+ rebuild-on-version-mismatch. Scan remains the fallback for anything the
+ index doesn't cover yet.
+3. **P2 β€” compaction + polish**: lazy shard compaction, leave-thread
+ cleanup, coverage surfacing in the results footer.
+4. **P3 β€” richer queries** (optional, later): attachment-filename terms
+ (filenames ride inside encrypted payloads, so they index client-side like
+ any other text), `from:` / `in:` filters (the entry fields already
+ exist), quoted phrases (positional postings β€” only if wanted).
+
+## Rejected
+
+- **Any server-side search**, including searchable-encryption schemes β€”
+ they leak query and access patterns to exactly the party the product
+ promises can see nothing. Off the table permanently.
+- **Trigram/substring index** β€” 3Γ— size for unfamiliar semantics.
+- **Cross-device index sync** β€” each device derives its own from the store
+ it already syncs; shipping index blobs around adds a surface for zero
+ user-visible gain.
+- **Plaintext index on web** β€” see the sealing analysis above.
diff --git a/web/static/search.js b/web/static/search.js
index e3717e5..232574c 100644
--- a/web/static/search.js
+++ b/web/static/search.js
@@ -11,7 +11,9 @@
// session β€” roughly the size of the conversation itself (tens of MB only at
// the far tail of huge, fully-backfilled spaces). Deliberately uncapped: an
// eviction scheme would silently narrow search coverage; the tab's lifetime
-// bounds it. Revisit if a real space ever makes this hurt.
+// bounds it. Revisit if a real space ever makes this hurt β€” the agreed
+// upgrade (a persisted, encrypted-at-rest term index; triggers, shape, and
+// invariants) is docs/sealed-search-index.md.
// Pure: does `text` match the query? Case-insensitive; every whitespace-
// separated term must appear (AND) β€” the familiar chat-search behaviour.