eleven / ocho Public
π€ 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'sSearchIndexdecrypts 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
MessageStorekeeps decrypted payloads in SQLite with an FTS5 twin table (messages_fts);MessageStore.searchis aMATCHjoin, 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:
- Semantics differ: web matches substrings (
matchTextusesincludes), native matches FTS5 word/prefix terms. Searching "ana" finds "banana" on web and not on iOS. This plan aligns both on word-prefix (below). - 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-storeDB):idx-shards(sealed term-shard blobs),idx-meta(per-thread indexed interval + schema version),idx-key(the CryptoKey). - Tokenization:
Intl.Segmenterword 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, andrunBackfill'sonPagehook (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-resultsscaffold 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
- P0 β semantics + measurement (small, anytime): align web
matchTexton 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. - 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.
- P2 β compaction + polish: lazy shard compaction, leave-thread cleanup, coverage surfacing in the results footer.
- 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.