Flash Persistence And Conversation Genome

Documentation for Architecture

Flash Persistence + Conversation Genome Architecture

Date: 2026-03-03 | Sessions: 166.3, 172, 173 | Author: Claude Code (Opus 4.6)

This document describes the unified chat persistence system, the Conversation Genome innovation for LLM context compression, and the reusable patterns that can be applied to any feature needing fast local + durable backend sync.

Session 172 update: The sync pattern was fundamentally revised. autoFlashState is now used as a cache layer for MongoDB with a hydration gate — the old two-signal pattern (_backendData signal) is deprecated. See Section 6.


Table of Contents

  1. Problem Statement
  2. Architecture Overview
  3. Database Layout
  4. The Five Layers
  5. Conversation Genome
  6. Reusable Pattern: Flash-to-Backend Sync
  7. Step-by-Step: Apply This Pattern to Any Feature
  8. Tiered Storage Lifecycle
  9. Key Files Reference
  10. Anti-Patterns to Avoid

1. Problem Statement

Three compounding gaps existed before Session 166.3:

Problem Impact Root Cause
Vitto chat had zero backend persistence Sessions lost when browser storage cleared sessionId never passed in POST body
Panel chat sync was best-effort Messages silently lost on backend failure No retry queue, no reconciliation
LLM context assembly was wasteful 10K+ tokens burned on raw history No semantic awareness of what's important

2. Architecture Overview

┌──────────────────────────────────────────────────────────────┐
│  FRONTEND (Angular)                                          │
│                                                              │
│  Any Feature State (chat, preferences, drafts, etc.)         │
│  autoFlashState → IndexedDB (instant, < 1ms)                 │
│           │                                                  │
│           ▼                                                  │
│  ChatBackendSyncService (@libs-ai/chat-sync)                 │
│  ├─ Fire-and-forget POST per mutation                        │
│  ├─ Persistent retry queue (survives page refresh)           │
│  ├─ Reconciliation on page load                              │
│  └─ NetworkSyncStateService (offline-aware)                  │
└──────────────────────┬───────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────┐
│  BACKEND (NestJS — backend-ai-api, port 3001)                │
│                                                              │
│  SessionsController                                          │
│  ├─ POST /:id/messages (append with auto-create)             │
│  ├─ POST /:id/reconcile (bidirectional diff)                 │
│  └─ GET /my/active, /my/history, /:id                        │
│                                                              │
│  ConversationGenomeService (context compression)             │
│  ChatEmbeddingService (vector search)                        │
│  ConversationCompressorService (tiered compression, @Cron)   │
│  SessionArchivalService (lifecycle management, @Cron)        │
└──────────────────────┬───────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────┐
│  MongoDB Atlas: gograbspace.dgqij2t.mongodb.net              │
│  Database: ai-agents                                         │
│  Collection: chat_sessions                                   │
│  ├─ messages[] with optional embedding[] per message         │
│  ├─ genome{} (Conversation Genome, structured representation)│
│  ├─ status: 'active' | 'archived' | 'cold'                  │
│  └─ source: 'panel' | 'vitto' | 'embedded' | 'desktop'      │
└──────────────────────────────────────────────────────────────┘

3. Database Layout

Cluster & Database

Property Value
Cluster gograbspace.dgqij2t.mongodb.net
Database ai-agents
Collection chat_sessions
Connection AI_CONNECTION_NAMES.AGENTS
Config libs/libs-api/backend-core/src/lib/config/database-names.config.ts

Why ai-agents?

ai-agents is the AI operational data hub (29 collections, 256 indexes). Chat sessions are AI operational data. Alternatives were rejected:

Schema Fields (Session 166.3 additions)

Field Type Purpose
genome Object Conversation Genome (structured representation)
status String enum Archival lifecycle: active → archived → cold
source String Origin: panel, vitto, embedded, extension, desktop, mobile
spaceId String Multi-space scoping
messages.$.embedding Number[] 1536-dim vector per message (OpenAI text-embedding-3-small)
messages.$.archived Boolean Tiered compression flag

Indexes

{ userId: 1, updatedAt: -1 }    — User session queries
{ status: 1 }                    — Archival lifecycle queries
{ context: 1, updatedAt: -1 }   — Panel context queries
{ createdAt: 1 }                 — Cleanup/TTL queries

4. The Five Layers

Layer 1: Instant Local (IndexedDB via autoFlashState)

What: User sees their action immediately. State saved to IndexedDB in < 1ms via autoFlashState.

How it works:

  1. Component calls addMessage(msg) on persistence service
  2. autoFlashState signal updates → UI refreshes (0ms)
  3. IndexedDB write happens in background (debounced 300ms)
  4. Cross-tab sync via BroadcastChannel (instant)

Key files:

Layer 2: Durable Backend (MongoDB via ChatBackendSyncService)

What: Every mutation also syncs to MongoDB (fire-and-forget). If backend is down, operations queue in IndexedDB and drain on reconnect.

How it works:

  1. After IndexedDB save, ChatBackendSyncService.appendMessages() fires
  2. POST /sessions/{id}/messages to backend-ai-api
  3. On failure → PendingSyncOp pushed to retryQueue (IndexedDB-persisted)
  4. NetworkSyncStateService.onBackendAvailable$ → drainRetryQueue()
  5. Max 5 retries, exponential backoff (1s → 16s)

Key files:

Layer 3: Reconciliation (on page load)

What: On every page load, compare local IndexedDB state vs backend MongoDB. Fix any drift in both directions.

How it works (Session 172):

  1. Wait for autoFlashState.hydrated → cached data loaded from IndexedDB
  2. Load backend session history → atomically merge INTO autoFlashState
  3. POST /sessions/{id}/reconcile with { localMessageIds, localUpdatedAt }
  4. Backend returns: { missingOnBackend[], missingLocally[] }
  5. Missing locally → merge into autoFlashState via state.update()
  6. Missing on backend → fire appendMessages() to re-sync

Why bidirectional: Fire-and-forget can fail silently. Local may have messages backend missed. Backend may have messages from another device.

Layer 4: Conversation Genome (LLM context compression)

What: Instead of sending 50 raw messages to the LLM, send a structured genome (~500 tokens) that encodes the same information 10x more efficiently.

See Section 5 for full details.

Layer 5: Tiered Storage Lifecycle

What: Sessions automatically move through hot → warm → cold tiers based on activity and age.

See Section 8 for full details.

5. Conversation Genome

What It Is

A ConversationGenome is a compact, structured, vectorized representation of an entire conversation:

interface ConversationGenome {
  version: string;
  sessionId: string;
  compiledAt: number;
  messageRange: { first: number; last: number };

  // Structured facts (~200-400 tokens)
  topics: string[];
  decisions: DecisionPoint[];     // { what, why, status }
  facts: FactEntry[];             // { fact, confidence, source }
  userIntent: string;
  openQuestions: string[];
  artifacts: ArtifactRef[];       // { type, path, description }

  // Semantic vector (1536-dim)
  genomeVector: number[];

  // Rolling summary (~200-500 tokens)
  summary: string;

  // Compression metadata
  totalMessages: number;
  compressionRatio: number;
  recentWindowStart: number;
}

How Context Assembly Changes

Before (wasteful):

System prompt → [50 raw messages, ~10K tokens] → user message → LLM
Total: ~12K tokens

After (Genome-powered):

System prompt
  → CONVERSATION GENOME (structured facts, ~400 tokens)
  → SEMANTIC RECALL (vector-retrieved relevant old snippets, ~300 tokens)
  → RECENT WINDOW (last 3-5 messages verbatim, ~500 tokens)
  → user message
Total: ~2.5K tokens (5-8x reduction)

Compilation Pipeline

Operation Trigger Cost Method
Full compile Every 5 messages ~$0.005 (Haiku) LLM structured extraction
Incremental update Every message Free Heuristic topic/artifact extraction
Hydration Every LLM call Free String formatting
Semantic recall On demand ~$0.00001 Vector similarity search

Integration Points

6. Reusable Pattern: Flash-to-Backend Sync

Session 172: This section was rewritten. The old two-signal pattern (_backendData signal) caused a flash-on-reload bug and is deprecated. The correct approach is autoFlashState as cache layer with a hydration gate.

This is the core pattern that can be applied to ANY feature needing instant local UX + durable backend storage.

The Pattern (Session 172)

┌──────────────────────────────────────────────────────────────┐
│  autoFlashState (IndexedDB cache for MongoDB)                │
│  ├─ .hydrated signal gates backend loading                   │
│  ├─ Backend data written INTO autoFlashState (atomic merge)  │
│  ├─ IndexedDB auto-updated on every state change             │
│  └─ Next page load: IndexedDB has backend-validated data     │
└──────────┬───────────────────────────────────────────────────┘
           │
           ▼ (write path: local → backend)
┌──────────────────────────────────────────────────────────────┐
│  BackendSync Service                                         │
│  ├─ Fire-and-forget HTTP to backend on each mutation         │
│  ├─ Persistent retry queue (IndexedDB via autoFlashState)    │
│  └─ NetworkSyncState-aware (queues when offline)             │
└──────────────────────────────────────────────────────────────┘

The Hydration Gate Pattern (critical)

// autoFlashState returns AutoFlashSignal<T> with .hydrated: Signal<boolean>
private readonly state = autoFlashState<MyState>('my-key', defaultState, config);

constructor() {
  effect(() => {
    // Gate 1: Wait for IndexedDB cache to hydrate
    if (!this.state.hydrated()) return;
    // Gate 2: Wait for user context
    const userId = this._userId();
    if (!userId) return;
    // Gate 3: Only load once
    if (this.backendLoadDone) return;
    this.backendLoadDone = true;
    this.loadFromBackend();
  });
}

private loadFromBackend(): void {
  this.http.get<BackendData>(url).subscribe(data => {
    // Write backend data INTO autoFlashState — atomic merge, no flash
    this.state.update(s => ({
      ...s,
      items: this.mergeBackendData(s.items, data),
    }));
  });
}

Why this works: state.hydrated() ensures IndexedDB data is loaded before backend HTTP fires. The merge is a single state.update() — atomic, no remove-then-add, no flash. autoFlashState auto-persists the merged data to IndexedDB. Next page load: IndexedDB has backend-validated data.

Why the old two-signal pattern was wrong: It stored backend data in a separate _backendData signal and merged via computed(). This caused stale IndexedDB data to appear for ~500ms on reload, then disappear when backend data arrived and cleanup ran. The hydration gate eliminates this race entirely.

The Retry Queue Pattern

// Persistent queue — survives page refresh
private readonly retryQueue = autoFlashState<PendingOp[]>('my-retry-queue', []);

// On failure → enqueue
onFailure(op) {
  this.retryQueue.update(q => [...q, { ...op, retryCount: 0, queuedAt: Date.now() }]);
}

// On reconnect → drain
networkState.onBackendAvailable$.subscribe(() => this.drainQueue());

// Dedup by unique ID to prevent double-sends

The Reconciliation Pattern

// On page load (AFTER hydration gate):
// 1. autoFlashState hydrates from IndexedDB (cached data shown instantly)
// 2. Backend history loaded → merged INTO autoFlashState (atomic, no flash)
// 3. POST /reconcile with local message IDs
// 4. Backend returns { missingOnBackend[], missingLocally[] }
// 5. Missing locally → merge INTO autoFlashState via state.update()
// 6. Missing on backend → fire appendMessages() to re-sync

7. Step-by-Step: Apply This Pattern to Any Feature

Session 172: Updated to use hydration gate + single-signal cache pattern.

Example: Applying to User Preferences Sync

Say you want user preferences (theme, language, layout) to survive device changes.

Step 1: Local State with autoFlashState

// autoFlashState returns AutoFlashSignal<T> — has .hydrated: Signal<boolean>
private readonly state = autoFlashState<UserPrefsState>('user-prefs', DEFAULT_STATE, {
  database: 'user-data',
  collection: 'preferences',
  priority: 'critical',
  debounceMs: 300,
});

Step 2: Gate Backend Loading on Hydration

private backendLoadDone = false;

constructor() {
  effect(() => {
    // Wait for IndexedDB cache to hydrate before loading backend
    if (!this.state.hydrated()) return;
    const userId = this._userId();
    if (!userId) return;
    if (this.backendLoadDone) return;
    this.backendLoadDone = true;
    this.loadFromBackend();
  });
}

private loadFromBackend(): void {
  this.http.get<UserPrefs>(`${this.apiUrl}/users/me/preferences`).pipe(
    catchError(() => of(null))
  ).subscribe(backend => {
    if (!backend) return;
    // Write backend data INTO autoFlashState — single signal, no flash
    this.state.update(s => ({ ...s, prefs: backend }));
  });
}

Step 3: Create a Sync Service (or reuse ChatBackendSyncService pattern)

@Injectable({ providedIn: 'root' })
export class PrefsBackendSyncService {
  private readonly retryQueue = autoFlashState<PendingOp[]>('prefs-sync-queue', []);
  private readonly networkState = inject(NetworkSyncStateService);

  syncPrefs(prefs: UserPrefs): void {
    if (!this.networkState.canMakeApiCalls()) {
      this.enqueue(prefs);
      return;
    }
    this.http.put(`${this.apiUrl}/users/me/preferences`, prefs)
      .pipe(catchError(() => { this.enqueue(prefs); return of(null); }))
      .subscribe();
  }
}

Step 4: Backend Endpoint

@Put('me/preferences')
async updatePreferences(@Body() prefs, @CurrentUser() user) {
  await this.prefsService.upsert(user.userId, prefs);
  return { success: true };
}

Checklist for Any New Feature Using This Pattern

8. Tiered Storage Lifecycle

HOT (IndexedDB + MongoDB active)
├─ Active conversations (< 7 days since last message)
├─ Recent conversations (< 100 messages)
├─ Genome for ALL conversations (always hot)
│
│  @Cron('0 2 * * *') — Daily 2 AM
│  Criteria: >100 messages AND >7 days inactive
│
WARM (MongoDB archived, IndexedDB cleaned)
├─ Full messages preserved in MongoDB
├─ Removed from IndexedDB (saves browser space)
├─ Genome stays hot (instant search/preview)
│
│  @Cron('0 4 * * 0') — Weekly Sunday 4 AM
│  Criteria: archived for 90+ days
│
COLD (MongoDB cold, messages archived)
├─ All messages marked archived
├─ Genome still searchable
├─ Retrievable on demand (1-3 seconds)

Critical rule: Genomes stay hot forever. This means semantic search, conversation list, and previews work across ALL conversations without fetching message arrays.

Smart retrieval when user opens archived session:

  1. Genome header shows instantly (0ms)
  2. Full messages stream from MongoDB (1-3s)
  3. User sends new message → session promoted back to active

9. Key Files Reference

Frontend (Angular)

File Purpose
libs/libs-ai/chat-sync/src/lib/services/chat-backend-sync.service.ts Unified sync, retry queue, reconciliation
libs/libs-ai/chat-sync/src/lib/models/chat-sync.models.ts Sync types
libs/libs-ai/vitto-chat/src/lib/services/vitto-chat-persistence.service.ts Vitto chat — reference implementation of Session 172 cache pattern
libs/libs-ai/panel-intelligence/src/lib/services/panel-session-persistence.service.ts (DEPRECATED) Panel chat — still uses old two-signal pattern
libs/libs-foundational/storage-system/src/flash/helpers/auto-flash-state.ts Zero-config persistent signals
libs/libs-foundational/storage-system/src/flash/services/flash-state-hydrator.service.ts Storage bridge, cross-context sync
libs/libs-foundational/storage-system/src/sync/services/network-sync-state.service.ts Network/backend availability detection

Backend (NestJS)

File Purpose
APIs/backend-ai-api/src/app/sessions/sessions.controller.ts REST endpoints (append, reconcile, CRUD)
APIs/backend-ai-api/src/app/sessions/sessions.service.ts Session CRUD + reconciliation logic
APIs/backend-ai-api/src/app/sessions/conversation-genome.service.ts Genome compile, hydrate, semantic recall
APIs/backend-ai-api/src/app/sessions/chat-embedding.service.ts Vector embeddings + semantic/hybrid search
APIs/backend-ai-api/src/app/sessions/conversation-compressor.service.ts Tiered compression cron
APIs/backend-ai-api/src/app/sessions/session-archival.service.ts Auto-archive + smart retrieval
APIs/backend-ai-api/src/app/sessions/sessions.schema.ts MongoDB schema

Configuration

File Purpose
libs/libs-api/backend-core/src/lib/config/database-names.config.ts AI_CONNECTION_NAMES.AGENTS → ai-agents database
tsconfig.base.json @libs-ai/chat-sync path mapping

10. Anti-Patterns to Avoid

Session 172: First row updated. The old two-signal pattern IS the anti-pattern now.

Anti-Pattern Why It's Wrong Correct Approach
Loading backend data without hydration gate Backend may respond before IndexedDB hydrates → merge operates on DEFAULT_STATE → stale data flash Gate backend loading on state.hydrated() (Session 172)
Separate _backendData signal for backend data Causes flash: stale IndexedDB sessions appear, then disappear when backend cleanup runs Write backend data INTO autoFlashState via state.update() — single signal
In-memory retry queue Lost on page refresh — the exact moment sync failures matter most autoFlashState persisted retry queue
scheduleBackendSync() with debounced POST Format mismatch between IndexedDB serialization and backend expectations Fire-and-forget in addMessage(), backend controls serialization
Sending full raw history to LLM Burns 10K+ tokens, most of which is low-signal Conversation Genome: structured facts + recent window
Pulling userId from localStorage for API calls Race condition on init, unreliable across contexts JWT soft-auth: @Public() + @CurrentUser()
One-directional sync (pull-only from backend) Local can have messages backend missed during failures Bidirectional reconciliation
Archiving without genome Archived sessions become black boxes with no searchability Always compile genome before archiving

This architecture was designed in Session 166.3 and revised in Session 172. The Session 172 hydration gate + cache-layer pattern is the canonical approach for any feature needing fast local + durable backend sync.