From 687acc9502b413565c2dfb541b36c4cdf5b7fbad Mon Sep 17 00:00:00 2001
From: Michael Libio <malibio@gmail.com>
Date: Mon, 23 Feb 2026 13:39:50 +0100
Subject: [PATCH 1/2] Implement AppServices container for hot-swappable
 database connections (#894)

Refactor Tauri backend from write-once app.manage() state to a centralized
AppServices container with Arc<RwLock<>> interior mutability, enabling
database switching at runtime without app restart.

- Add app_services.rs with AppServices container, accessor methods, and
  drain-then-replace switch_database() protocol
- Update all 13 command files to use State<'_, AppServices> with async
  accessor pattern
- Update db.rs to populate AppServices instead of individual app.manage()
- Update settings.rs with hot-swap flow (select_new_database now switches
  live instead of requiring restart)
- Update frontend to handle database-changed events without restart dialog
- Document embedding sync boundaries in sync-protocol.md (local commit
  before sync, subtree atomic sync units)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 docs/architecture/data/sync-protocol.md       |  58 ++++
 .../desktop-app/src-tauri/src/app_services.rs | 251 ++++++++++++++++++
 .../src-tauri/src/commands/collections.rs     |  66 +++--
 .../desktop-app/src-tauri/src/commands/db.rs  | 121 ++++-----
 .../src-tauri/src/commands/diagnostics.rs     |  14 +-
 .../src-tauri/src/commands/embeddings.rs      |  75 +++---
 .../src-tauri/src/commands/import.rs          |  14 +-
 .../src-tauri/src/commands/nodes.rs           |  58 ++--
 .../src-tauri/src/commands/schemas.rs         |  19 +-
 .../src-tauri/src/commands/settings.rs        | 190 +++++++++++--
 packages/desktop-app/src-tauri/src/lib.rs     | 134 ++++------
 .../lib/components/layout/app-shell.svelte    |  23 +-
 .../sections/database-settings.svelte         |  24 +-
 13 files changed, 740 insertions(+), 307 deletions(-)
 create mode 100644 packages/desktop-app/src-tauri/src/app_services.rs

diff --git a/docs/architecture/data/sync-protocol.md b/docs/architecture/data/sync-protocol.md
index 35983146a..17243f1c7 100644
--- a/docs/architecture/data/sync-protocol.md
+++ b/docs/architecture/data/sync-protocol.md
@@ -446,6 +446,64 @@ class OperationalTransformer {
 
 ## Vector Embedding Synchronization
 
+### Embedding Sync Boundaries — Local Commit Before Sync
+
+**Core principle: Sync operates on committed state, not in-progress state.**
+
+A node is not considered "committed" until all its derived data (including embeddings)
+has been computed. This mirrors database transaction semantics — partial writes are
+never exposed to other readers. The sync outbox only contains fully resolved nodes.
+
+**Rules:**
+
+1. **A node does not enter the sync outbox until its embedding is complete.** When
+   content changes, the node is marked stale locally and held back from sync until
+   re-embedding finishes. The sync layer never sees intermediate state.
+
+2. **Stale markers are local-only.** They drive the local embedding pipeline and are
+   never transmitted to other clients. No client should ever receive a node in a
+   stale/pending state.
+
+3. **Each synced node is an atomic unit:** content + embedding vector + content hash
+   (`sha256(content)`) + model ID. Receiving clients can trust that the embedding
+   matches the content.
+
+```
+Sync outbox eligibility:
+  ✅ Root node with completed embedding (content_hash covers root + all children)
+     → entire subtree (root + all descendants) syncs as one atomic unit
+  ❌ Root node with stale/pending embedding → entire subtree held back
+  ❌ Stale markers (never synced)
+```
+
+**Subtree as atomic sync unit**: Embeddings are computed only at the root node
+level, incorporating the content of all descendant nodes. Therefore the sync
+unit is the **entire subtree** — root node + all children. If a child node's
+content changes, the root's embedding becomes stale, and the whole subtree is
+held from the sync outbox until re-embedding completes. When it does, the root
+and all its descendants sync together as one atomic operation.
+
+**Edge cases:**
+
+- **Database switch before re-embedding completes**: The pending nodes remain in the
+  local database but never enter the sync outbox. If the database is never opened
+  again, those nodes simply don't sync — which is correct, since no client is
+  actively using that database.
+
+- **Embedding model failure**: If embeddings cannot be computed (GPU error, model
+  won't load), affected nodes are held from sync. This is surfaced as a user-visible
+  error. The sync layer does not attempt to work around broken local state.
+
+- **Node update with pending re-embedding**: When an existing node's content changes,
+  the sync outbox retains the last committed version (previous content + its matching
+  embedding) until the new embedding completes. Once re-embedding finishes, the update
+  becomes the new committed state and replaces the previous version in the outbox.
+  This is analogous to offline edits — the user keeps working locally, but the sync
+  layer only transmits fully resolved state.
+
+- **Receiving a synced node**: Because only committed nodes are synced, the receiver
+  accepts the node + embedding as-is. No receiver-side audit or re-embedding needed.
+
 ### Efficient Vector Sync
 ```rust
 pub struct VectorSyncOptimizer {
diff --git a/packages/desktop-app/src-tauri/src/app_services.rs b/packages/desktop-app/src-tauri/src/app_services.rs
new file mode 100644
index 000000000..9c6f24ae7
--- /dev/null
+++ b/packages/desktop-app/src-tauri/src/app_services.rs
@@ -0,0 +1,251 @@
+//! Centralized application services container with interior mutability.
+//!
+//! `AppServices` wraps all runtime services (database, node service, embeddings)
+//! behind `Arc<RwLock<>>` so they can be hot-swapped at runtime when the user
+//! switches databases — without restarting the entire application.
+//!
+//! Registered as a single Tauri managed state via `app.manage(AppServices::new())`.
+//! All commands access services through `State<'_, AppServices>`.
+
+use crate::commands::nodes::CommandError;
+use crate::config::AppConfig;
+use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService};
+use nodespace_core::{NodeService, SurrealStore};
+use std::sync::Arc;
+use tokio::sync::RwLock;
+use tokio_util::sync::CancellationToken;
+
+/// Application state containing embedding service and processor.
+///
+/// Moved from `commands/embeddings.rs` to centralize in AppServices.
+pub struct EmbeddingState {
+    pub service: Arc<NodeEmbeddingService>,
+    pub processor: Arc<EmbeddingProcessor>,
+}
+
+/// Active services that are initialized after database connection.
+///
+/// All fields are populated during `init_services()` and can be
+/// replaced atomically during `switch_database()`.
+struct ActiveServices {
+    store: Arc<SurrealStore>,
+    node_service: Arc<NodeService>,
+    embedding_state: Option<EmbeddingState>,
+    config: AppConfig,
+}
+
+/// Centralized services container with interior mutability for hot-swapping.
+///
+/// Registered as Tauri managed state. Commands access services via accessor methods
+/// that return `Result<Arc<T>, CommandError>` — returning a clear error if services
+/// aren't initialized yet.
+pub struct AppServices {
+    inner: Arc<RwLock<Option<ActiveServices>>>,
+    /// Per-session cancellation token for background tasks (MCP, domain event forwarder).
+    /// Cancelled and replaced on each `switch_database()` call.
+    session_token: Arc<RwLock<Option<CancellationToken>>>,
+}
+
+impl Default for AppServices {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl AppServices {
+    /// Create an empty container. Services are populated later via `initialize()`.
+    pub fn new() -> Self {
+        Self {
+            inner: Arc::new(RwLock::new(None)),
+            session_token: Arc::new(RwLock::new(None)),
+        }
+    }
+
+    /// Get the NodeService, or error if not yet initialized.
+    pub async fn node_service(&self) -> Result<Arc<NodeService>, CommandError> {
+        let guard = self.inner.read().await;
+        guard
+            .as_ref()
+            .map(|s| s.node_service.clone())
+            .ok_or_else(|| CommandError {
+                message: "Database not initialized. Please wait for startup to complete."
+                    .to_string(),
+                code: "NOT_INITIALIZED".to_string(),
+                details: None,
+            })
+    }
+
+    /// Get the SurrealStore, or error if not yet initialized.
+    pub async fn store(&self) -> Result<Arc<SurrealStore>, CommandError> {
+        let guard = self.inner.read().await;
+        guard
+            .as_ref()
+            .map(|s| s.store.clone())
+            .ok_or_else(|| CommandError {
+                message: "Database not initialized. Please wait for startup to complete."
+                    .to_string(),
+                code: "NOT_INITIALIZED".to_string(),
+                details: None,
+            })
+    }
+
+    /// Get the EmbeddingState, or error if not initialized or embeddings unavailable.
+    pub async fn embedding_state(
+        &self,
+    ) -> Result<(Arc<NodeEmbeddingService>, Arc<EmbeddingProcessor>), CommandError> {
+        let guard = self.inner.read().await;
+        let active = guard.as_ref().ok_or_else(|| CommandError {
+            message: "Database not initialized. Please wait for startup to complete.".to_string(),
+            code: "NOT_INITIALIZED".to_string(),
+            details: None,
+        })?;
+
+        match &active.embedding_state {
+            Some(es) => Ok((es.service.clone(), es.processor.clone())),
+            None => Err(CommandError {
+                message: "Embedding service not available. Model may have failed to load."
+                    .to_string(),
+                code: "EMBEDDINGS_UNAVAILABLE".to_string(),
+                details: None,
+            }),
+        }
+    }
+
+    /// Get just the embedding service Arc (for MCP integration).
+    pub async fn embedding_service(&self) -> Result<Arc<NodeEmbeddingService>, CommandError> {
+        self.embedding_state().await.map(|(svc, _)| svc)
+    }
+
+    /// Get the AppConfig, or error if not yet initialized.
+    pub async fn config(&self) -> Result<AppConfig, CommandError> {
+        let guard = self.inner.read().await;
+        guard
+            .as_ref()
+            .map(|s| s.config.clone())
+            .ok_or_else(|| CommandError {
+                message: "Database not initialized. Please wait for startup to complete."
+                    .to_string(),
+                code: "NOT_INITIALIZED".to_string(),
+                details: None,
+            })
+    }
+
+    /// Check whether services have been initialized.
+    pub async fn is_initialized(&self) -> bool {
+        self.inner.read().await.is_some()
+    }
+
+    /// Populate the container with initialized services.
+    ///
+    /// Called from `db.rs::init_services()` after database and services are ready.
+    pub async fn initialize(
+        &self,
+        store: Arc<SurrealStore>,
+        node_service: Arc<NodeService>,
+        embedding_state: Option<EmbeddingState>,
+        config: AppConfig,
+        session_cancel_token: CancellationToken,
+    ) {
+        {
+            let mut guard = self.inner.write().await;
+            *guard = Some(ActiveServices {
+                store,
+                node_service,
+                embedding_state,
+                config,
+            });
+        }
+        {
+            let mut token_guard = self.session_token.write().await;
+            *token_guard = Some(session_cancel_token);
+        }
+    }
+
+    /// Hot-swap database: cancel session tasks, replace services, restart background tasks.
+    ///
+    /// The drain-then-replace protocol:
+    /// 1. Cancel the current session token (stops MCP server, domain event forwarder)
+    /// 2. Brief pause for background tasks to exit
+    /// 3. Release GPU resources from old embedding state
+    /// 4. Replace inner services with new ones
+    /// 5. Set new session token
+    /// 6. Caller restarts background tasks with new services
+    pub async fn switch_database(
+        &self,
+        store: Arc<SurrealStore>,
+        node_service: Arc<NodeService>,
+        embedding_state: Option<EmbeddingState>,
+        config: AppConfig,
+        new_session_token: CancellationToken,
+    ) {
+        // Step 1: Cancel current session token
+        {
+            let token_guard = self.session_token.read().await;
+            if let Some(token) = token_guard.as_ref() {
+                token.cancel();
+            }
+        }
+
+        // Step 2: Drain old embedding processor before releasing GPU.
+        // The processor's background task shuts down when its Arc is dropped
+        // (dropping the shutdown channel sender). We take ownership here so the
+        // old processor stops before we release the GPU context it depends on.
+        let old_embedding_state = {
+            let mut guard = self.inner.write().await;
+            guard
+                .as_mut()
+                .and_then(|active| active.embedding_state.take())
+        };
+
+        // Brief pause for background tasks (MCP, forwarder, processor) to exit
+        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+        // Step 3: Release GPU resources from old embedding state.
+        // The processor Arc should be the last reference — dropping it shuts down
+        // the background task. Then we can safely release the GPU context.
+        if let Some(old_es) = old_embedding_state {
+            // Drop processor first to stop its background task
+            let old_service = old_es.service;
+            drop(old_es.processor);
+            // Brief pause for processor task to exit
+            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+            tracing::info!("Releasing GPU context from old embedding state...");
+            old_service.nlp_engine().release_gpu_context();
+            tracing::info!("GPU context released successfully");
+        }
+
+        // Step 4: Replace services
+        {
+            let mut guard = self.inner.write().await;
+            *guard = Some(ActiveServices {
+                store,
+                node_service,
+                embedding_state,
+                config,
+            });
+        }
+
+        // Step 5: Set new session token
+        {
+            let mut token_guard = self.session_token.write().await;
+            *token_guard = Some(new_session_token);
+        }
+    }
+
+    /// Get the current session cancellation token (for background task coordination).
+    pub async fn session_token(&self) -> Option<CancellationToken> {
+        self.session_token.read().await.clone()
+    }
+
+    /// Release GPU resources. Called during graceful shutdown.
+    pub async fn release_gpu_resources(&self) {
+        let guard = self.inner.read().await;
+        if let Some(active) = guard.as_ref() {
+            if let Some(ref es) = active.embedding_state {
+                tracing::info!("Releasing GPU context to prevent Metal crash...");
+                es.service.nlp_engine().release_gpu_context();
+                tracing::info!("GPU context released successfully");
+            }
+        }
+    }
+}
diff --git a/packages/desktop-app/src-tauri/src/commands/collections.rs b/packages/desktop-app/src-tauri/src/commands/collections.rs
index eaef6b900..1e8c3c066 100644
--- a/packages/desktop-app/src-tauri/src/commands/collections.rs
+++ b/packages/desktop-app/src-tauri/src/commands/collections.rs
@@ -6,13 +6,14 @@
 //! - Path-based collection operations
 
 use nodespace_core::services::CollectionService;
-use nodespace_core::{models, Node, NodeService};
+use nodespace_core::{models, Node};
 use serde::Serialize;
 use serde_json::Value;
 use tauri::State;
 
 use super::nodes::CommandError;
 
+use crate::app_services::AppServices;
 use crate::constants::TAURI_CLIENT_ID;
 
 /// Convert a Node to its strongly-typed JSON representation
@@ -55,7 +56,7 @@ pub struct CollectionInfo {
 /// avoiding N+1 query pattern for better performance.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 ///
 /// # Returns
 /// * `Ok(Vec<CollectionInfo>)` - All collection nodes with member counts
@@ -68,8 +69,9 @@ pub struct CollectionInfo {
 /// ```
 #[tauri::command]
 pub async fn get_all_collections(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
 ) -> Result<Vec<CollectionInfo>, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -105,7 +107,7 @@ pub async fn get_all_collections(
 /// Single query that traverses the relationship and returns full Node data.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `collection_id` - ID of the collection to get members for
 ///
 /// # Returns
@@ -120,9 +122,10 @@ pub async fn get_all_collections(
 /// ```
 #[tauri::command]
 pub async fn get_collection_members(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     collection_id: String,
 ) -> Result<Vec<Value>, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
 
     // Single query: traverse relationship and get full node data
@@ -144,7 +147,7 @@ pub async fn get_collection_members(
 /// descendant collections.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `collection_id` - ID of the collection
 ///
 /// # Returns
@@ -152,9 +155,10 @@ pub async fn get_collection_members(
 /// * `Err(CommandError)` - Error if query fails
 #[tauri::command]
 pub async fn get_collection_members_recursive(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     collection_id: String,
 ) -> Result<Vec<Value>, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -191,7 +195,7 @@ pub async fn get_collection_members_recursive(
 /// Returns collection node IDs that the specified node is a member of.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `node_id` - ID of the node to query
 ///
 /// # Returns
@@ -206,9 +210,10 @@ pub async fn get_collection_members_recursive(
 /// ```
 #[tauri::command]
 pub async fn get_node_collections(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
 ) -> Result<Vec<String>, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -227,7 +232,7 @@ pub async fn get_node_collections(
 /// Creates a member_of edge from the node to the collection.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `node_id` - ID of the node to add
 /// * `collection_id` - ID of the collection to add to
 ///
@@ -244,10 +249,11 @@ pub async fn get_node_collections(
 /// ```
 #[tauri::command]
 pub async fn add_node_to_collection(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
     collection_id: String,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -266,7 +272,7 @@ pub async fn add_node_to_collection(
 /// Resolves the path, creates any missing collections, and adds the node.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `node_id` - ID of the node to add
 /// * `collection_path` - Path like "hr:policy:vacation"
 ///
@@ -283,10 +289,11 @@ pub async fn add_node_to_collection(
 /// ```
 #[tauri::command]
 pub async fn add_node_to_collection_path(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
     collection_path: String,
 ) -> Result<String, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -307,7 +314,7 @@ pub async fn add_node_to_collection_path(
 /// Deletes the member_of edge from the node to the collection.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `node_id` - ID of the node to remove
 /// * `collection_id` - ID of the collection to remove from
 ///
@@ -324,10 +331,11 @@ pub async fn add_node_to_collection_path(
 /// ```
 #[tauri::command]
 pub async fn remove_node_from_collection(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
     collection_id: String,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -346,7 +354,7 @@ pub async fn remove_node_from_collection(
 /// Searches for an existing collection matching the given path.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `collection_path` - Path like "hr:policy:vacation"
 ///
 /// # Returns
@@ -355,9 +363,10 @@ pub async fn remove_node_from_collection(
 /// * `Err(CommandError)` - Error if query fails
 #[tauri::command]
 pub async fn find_collection_by_path(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     collection_path: String,
 ) -> Result<Option<Value>, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -379,7 +388,7 @@ pub async fn find_collection_by_path(
 /// Get collection by name (case-insensitive)
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `name` - Collection name to find
 ///
 /// # Returns
@@ -388,9 +397,10 @@ pub async fn find_collection_by_path(
 /// * `Err(CommandError)` - Error if query fails
 #[tauri::command]
 pub async fn get_collection_by_name(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     name: String,
 ) -> Result<Option<Value>, CommandError> {
+    let service = services.node_service().await?;
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
 
@@ -414,7 +424,7 @@ pub async fn get_collection_by_name(
 /// Creates a collection node with the given name.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `name` - Name for the new collection
 /// * `description` - Optional description
 ///
@@ -423,13 +433,15 @@ pub async fn get_collection_by_name(
 /// * `Err(CommandError)` - Error if collection already exists or creation fails
 #[tauri::command]
 pub async fn create_collection(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     name: String,
     description: Option<String>,
 ) -> Result<String, CommandError> {
     use nodespace_core::services::CreateNodeParams;
     use serde_json::json;
 
+    let service = services.node_service().await?;
+
     // Check if collection with this name already exists
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
@@ -482,7 +494,7 @@ pub async fn create_collection(
 /// Updates the collection's content (name) field.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `collection_id` - ID of the collection to rename
 /// * `version` - Expected version for OCC
 /// * `new_name` - New name for the collection
@@ -492,13 +504,15 @@ pub async fn create_collection(
 /// * `Err(CommandError)` - Error if rename fails
 #[tauri::command]
 pub async fn rename_collection(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     collection_id: String,
     version: i64,
     new_name: String,
 ) -> Result<Value, CommandError> {
     use nodespace_core::NodeUpdate;
 
+    let service = services.node_service().await?;
+
     // Check if name is already taken by another collection
     let store = service.store();
     let collection_service = CollectionService::new(store, &service);
@@ -546,7 +560,7 @@ pub async fn rename_collection(
 /// membership edges are removed.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 /// * `collection_id` - ID of the collection to delete
 /// * `version` - Expected version for OCC
 ///
@@ -555,10 +569,12 @@ pub async fn rename_collection(
 /// * `Err(CommandError)` - Error if delete fails
 #[tauri::command]
 pub async fn delete_collection(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     collection_id: String,
     version: i64,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
+
     // Delete the collection node (member_of edges will be cleaned up by cascade)
     service
         .with_client(TAURI_CLIENT_ID)
diff --git a/packages/desktop-app/src-tauri/src/commands/db.rs b/packages/desktop-app/src-tauri/src/commands/db.rs
index d6c7c73c8..40ca4cf45 100644
--- a/packages/desktop-app/src-tauri/src/commands/db.rs
+++ b/packages/desktop-app/src-tauri/src/commands/db.rs
@@ -2,8 +2,9 @@
 //!
 //! As of Issue #676, NodeOperations layer is removed - NodeService contains all business logic.
 //! As of Issue #690, SchemaService is removed - schema operations use NodeService directly.
+//! As of Issue #894, services are registered via AppServices container for hot-swappable DB.
 
-use crate::commands::embeddings::EmbeddingState;
+use crate::app_services::{AppServices, EmbeddingState};
 use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService};
 use nodespace_core::{NodeService, SurrealStore};
 use nodespace_nlp_engine::{EmbeddingConfig, EmbeddingService};
@@ -20,13 +21,6 @@ use crate::constants::EMBEDDING_MODEL_FILENAME;
 /// Checks multiple locations in order:
 /// 1. Bundled resources (for production builds)
 /// 2. User's ~/.nodespace/models/ directory (fallback for dev)
-///
-/// # Arguments
-/// * `app` - Tauri application handle for resource resolution
-///
-/// # Returns
-/// * `Ok(PathBuf)` - Path to the GGUF model file
-/// * `Err(String)` - Error if model not found anywhere
 fn resolve_bundled_model_path(app: &AppHandle) -> Result<PathBuf, String> {
     // Try bundled resources first (production builds)
     if let Ok(resource_path) = app.path().resolve(
@@ -57,30 +51,25 @@ fn resolve_bundled_model_path(app: &AppHandle) -> Result<PathBuf, String> {
     ))
 }
 
-/// Initialize database services using AppConfig from Tauri state.
+/// Initialize database services and populate AppServices container.
 ///
-/// Reads database path, model path, and client ID from AppConfig which
-/// must be registered as Tauri state before calling this function.
-///
-/// # State Management
-/// Uses Tauri's state management via `app.manage()`. Once initialized,
-/// services persist for the application lifetime. To change database location,
-/// the application must be restarted.
-async fn init_services(app: &AppHandle) -> Result<(), String> {
+/// Reads database path, model path, and client ID from AppConfig.
+/// Populates AppServices with store, node_service, and embedding state.
+/// Starts background tasks (MCP server, domain event forwarder).
+async fn init_services(app: &AppHandle, config: &crate::config::AppConfig) -> Result<(), String> {
     eprintln!("🔧 [init_services] Starting service initialization...");
     tracing::info!("🔧 [init_services] Starting service initialization...");
 
-    // Read config from Tauri state (registered during app startup)
-    let config: tauri::State<crate::config::AppConfig> = app.state();
     let db_path = config.database_path.clone();
     let model_path = config.model_path.clone();
     let client_id = config.tauri_client_id.clone();
 
-    // Check if state already exists to prevent reinitialization
-    if app.try_state::<SurrealStore>().is_some() {
+    // Check if already initialized via AppServices
+    let services: tauri::State<AppServices> = app.state();
+    if services.is_initialized().await {
         eprintln!("⚠️  [init_services] Database already initialized");
         return Err(
-            "Database already initialized. Restart the app to change location.".to_string(),
+            "Database already initialized. Use switch_database for hot-swapping.".to_string(),
         );
     }
 
@@ -96,7 +85,6 @@ async fn init_services(app: &AppHandle) -> Result<(), String> {
     tracing::info!("✅ [init_services] SurrealDB store initialized");
 
     // Initialize node service with SurrealStore
-    // NodeService::new() takes &mut Arc to enable cache updates during seeding (Issue #704)
     tracing::info!("🔧 [init_services] Initializing NodeService...");
     let mut node_service = NodeService::new(&mut store)
         .await
@@ -132,53 +120,58 @@ async fn init_services(app: &AppHandle) -> Result<(), String> {
         .map_err(|e| format!("Failed to initialize embedding processor: {}", e))?;
 
     // Wire up NodeService to wake processor on embedding changes (Issue #729)
-    // This enables event-driven embedding processing without polling
     node_service.set_embedding_waker(processor.waker());
     tracing::info!("✅ [init_services] EmbeddingProcessor waker connected to NodeService");
 
     // Wake processor on startup to process any existing stale embeddings
-    // This handles cases where stale markers exist from previous sessions
     processor.wake();
     tracing::info!("🔔 [init_services] EmbeddingProcessor woken to process stale embeddings");
 
     let node_service_arc = Arc::new(node_service);
     let processor_arc = Arc::new(processor);
 
-    // Manage all services
-    eprintln!("🔧 [init_services] Registering services with Tauri app.manage()...");
-    tracing::info!("🔧 [init_services] Registering services with Tauri app.manage()...");
-    app.manage(store.clone());
-    app.manage(node_service_arc.as_ref().clone());
-    // NOTE: NodeOperations removed (Issue #676) - commands use NodeService directly
-    // NOTE: SchemaService removed (Issue #690) - schema commands use NodeService directly
-    app.manage(EmbeddingState {
-        service: embedding_service_arc,
-        processor: processor_arc.clone(),
-    });
-    app.manage(processor_arc);
-    eprintln!("✅ [init_services] All services registered with Tauri");
-    tracing::info!("✅ [init_services] All services registered with Tauri");
-
-    // Retrieve the shutdown token from Tauri state for background task coordination
+    // Retrieve the shutdown token for background task coordination
     let shutdown_token: tauri::State<crate::ShutdownToken> = app.state();
+    let session_token = shutdown_token.child_token();
+
+    // Populate AppServices container (Issue #894)
+    eprintln!("🔧 [init_services] Populating AppServices container...");
+    tracing::info!("🔧 [init_services] Populating AppServices container...");
+    services
+        .initialize(
+            store.clone(),
+            node_service_arc.clone(),
+            Some(EmbeddingState {
+                service: embedding_service_arc.clone(),
+                processor: processor_arc.clone(),
+            }),
+            config.clone(),
+            session_token.clone(),
+        )
+        .await;
+    eprintln!("✅ [init_services] AppServices container populated");
+    tracing::info!("✅ [init_services] AppServices container populated");
 
     // Initialize MCP server now that NodeService is available
-    // MCP will use the same NodeService as Tauri commands
-    if let Err(e) = crate::initialize_mcp_server(app.clone(), shutdown_token.child_token()) {
+    // Pass services directly instead of reading from Tauri state
+    if let Err(e) = crate::initialize_mcp_server(
+        app.clone(),
+        node_service_arc.clone(),
+        embedding_service_arc.clone(),
+        session_token.clone(),
+    ) {
         tracing::error!("❌ Failed to initialize MCP server: {}", e);
         // Don't fail database init if MCP fails - MCP is optional
     }
 
     // Initialize domain event forwarding with client filtering (#665)
-    // Events that originated from this Tauri client are filtered out to prevent feedback loops
     if let Err(e) = crate::initialize_domain_event_forwarder(
         app.clone(),
         node_service_arc.clone(),
         client_id,
-        shutdown_token.child_token(),
+        session_token.clone(),
     ) {
         tracing::error!("❌ Failed to initialize domain event forwarder: {}", e);
-        // Don't fail database init if event forwarding fails - it's not critical
     }
 
     let _ = store; // Store still available for direct access if needed
@@ -192,29 +185,6 @@ async fn init_services(app: &AppHandle) -> Result<(), String> {
 /// Checks for previously saved database location preference. If found,
 /// uses that path. Otherwise, uses unified ~/.nodespace/database/ location
 /// across all platforms.
-///
-/// This command should be called during application startup before any
-/// database operations are attempted.
-///
-/// # Arguments
-/// * `app` - Tauri application handle
-///
-/// # Returns
-/// * `Ok(String)` - Path to the initialized database file
-/// * `Err(String)` - Error if initialization fails
-///
-/// # Default Location (New Unified Path)
-/// - All platforms: ~/.nodespace/database/nodespace.db
-///
-/// # Migration
-/// Automatically migrates existing databases from old platform-specific
-/// locations on first run.
-///
-/// # Errors
-/// Returns error if:
-/// - Database services are already initialized
-/// - Cannot determine home directory
-/// - Database initialization fails
 #[tauri::command]
 pub async fn initialize_database(app: AppHandle) -> Result<String, String> {
     // Attempt migration from old location
@@ -245,12 +215,15 @@ pub async fn initialize_database(app: AppHandle) -> Result<String, String> {
         .and_then(|p| p.parse::<u16>().ok())
         .unwrap_or(3100);
 
-    // Build and register AppConfig as Tauri state
+    // Build AppConfig
     let config = crate::config::AppConfig::from_preferences(&prefs, model_path, mcp_port)?;
-    app.manage(config);
 
-    // Initialize services (reads config from Tauri state)
-    init_services(&app).await?;
+    // Show database path on startup
+    let db_path_str = db_path.to_string_lossy().to_string();
+    eprintln!("📂 Database path: {}", db_path_str);
+
+    // Initialize services (populates AppServices container)
+    init_services(&app, &config).await?;
 
-    Ok(db_path.to_string_lossy().to_string())
+    Ok(db_path_str)
 }
diff --git a/packages/desktop-app/src-tauri/src/commands/diagnostics.rs b/packages/desktop-app/src-tauri/src/commands/diagnostics.rs
index a9d9b186d..a5a977379 100644
--- a/packages/desktop-app/src-tauri/src/commands/diagnostics.rs
+++ b/packages/desktop-app/src-tauri/src/commands/diagnostics.rs
@@ -3,12 +3,12 @@
 //! These commands provide insight into the database state for debugging
 //! issues where nodes don't persist on some machines.
 
+use crate::app_services::AppServices;
 use nodespace_core::services::CreateNodeParams;
-use nodespace_core::{NodeQuery, NodeService, SurrealStore};
+use nodespace_core::NodeQuery;
 use serde::Serialize;
 use std::fs;
 use std::path::PathBuf;
-use std::sync::Arc;
 use tauri::State;
 
 /// Diagnostic info about the database state
@@ -63,7 +63,7 @@ fn get_directory_size(path: &PathBuf) -> Option<u64> {
 /// persistence issues on different machines.
 ///
 /// # Arguments
-/// * `store` - SurrealStore instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 ///
 /// # Returns
 /// * `DatabaseDiagnostics` - Struct with all diagnostic info
@@ -76,8 +76,9 @@ fn get_directory_size(path: &PathBuf) -> Option<u64> {
 /// ```
 #[tauri::command]
 pub async fn get_database_diagnostics(
-    store: State<'_, Arc<SurrealStore>>,
+    services: State<'_, AppServices>,
 ) -> Result<DatabaseDiagnostics, String> {
+    let store = services.store().await.map_err(|e| e.message)?;
     let mut errors: Vec<String> = Vec::new();
 
     // Get database path from environment or default
@@ -188,7 +189,7 @@ pub struct TestPersistenceResult {
 /// diagnostic info about the operation. Useful for testing if persistence works.
 ///
 /// # Arguments
-/// * `service` - NodeService instance from Tauri state
+/// * `services` - AppServices instance from Tauri state
 ///
 /// # Returns
 /// * Result with creation and verification details
@@ -201,8 +202,9 @@ pub struct TestPersistenceResult {
 /// ```
 #[tauri::command]
 pub async fn test_node_persistence(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
 ) -> Result<TestPersistenceResult, String> {
+    let service = services.node_service().await.map_err(|e| e.message)?;
     let test_id = format!("diagnostic-test-{}", uuid::Uuid::new_v4());
     let test_content = format!("Diagnostic test node created at {}", chrono::Utc::now());
 
diff --git a/packages/desktop-app/src-tauri/src/commands/embeddings.rs b/packages/desktop-app/src-tauri/src/commands/embeddings.rs
index 2a7db6d06..62167a2c0 100644
--- a/packages/desktop-app/src-tauri/src/commands/embeddings.rs
+++ b/packages/desktop-app/src-tauri/src/commands/embeddings.rs
@@ -5,22 +5,14 @@
 //! - Searching topics by semantic similarity
 //! - Updating embeddings on content changes
 
+use crate::app_services::AppServices;
 use crate::commands::nodes::CommandError;
 use nodespace_core::models::Node;
-use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService};
-use nodespace_core::NodeService;
 use serde::{Deserialize, Serialize};
-use std::sync::Arc;
 use tauri::State;
 
 use crate::constants::TAURI_CLIENT_ID;
 
-/// Application state containing embedding service and processor
-pub struct EmbeddingState {
-    pub service: Arc<NodeEmbeddingService>,
-    pub processor: Arc<EmbeddingProcessor>,
-}
-
 /// Helper to create CommandError instances
 fn command_error(message: impl Into<String>, code: impl Into<String>) -> CommandError {
     CommandError {
@@ -67,10 +59,12 @@ fn command_error_with_details(
 /// ```
 #[tauri::command]
 pub async fn generate_root_embedding(
-    state: State<'_, EmbeddingState>,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     root_id: String,
 ) -> Result<(), CommandError> {
+    let (embedding_service, _) = services.embedding_state().await?;
+    let node_service = services.node_service().await?;
+
     // Get the node from the database
     let node = node_service
         .get_node(&root_id)
@@ -85,8 +79,7 @@ pub async fn generate_root_embedding(
         .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?;
 
     // Queue node's root for embedding via root-aggregate model (Issue #729)
-    state
-        .service
+    embedding_service
         .queue_for_embedding(&node.id)
         .await
         .map_err(|e| {
@@ -163,10 +156,12 @@ pub struct SearchRootsParams {
 /// ```
 #[tauri::command]
 pub async fn search_roots(
-    state: State<'_, EmbeddingState>,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     params: SearchRootsParams,
 ) -> Result<Vec<Node>, CommandError> {
+    let (embedding_service, _) = services.embedding_state().await?;
+    let node_service = services.node_service().await?;
+
     // Validate query parameter
     if params.query.trim().is_empty() {
         return Err(command_error(
@@ -186,8 +181,7 @@ pub async fn search_roots(
     }
 
     // Generate embedding for search query
-    let query_embedding = state
-        .service
+    let query_embedding = embedding_service
         .nlp_engine()
         .generate_embedding(&params.query)
         .map_err(|e| {
@@ -249,10 +243,12 @@ pub async fn search_roots(
 /// ```
 #[tauri::command]
 pub async fn update_root_embedding(
-    state: State<'_, EmbeddingState>,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     root_id: String,
 ) -> Result<(), CommandError> {
+    let (embedding_service, _) = services.embedding_state().await?;
+    let node_service = services.node_service().await?;
+
     // Get the node from the database
     let node = node_service
         .get_node(&root_id)
@@ -267,8 +263,7 @@ pub async fn update_root_embedding(
         .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?;
 
     // Queue node's root for embedding via root-aggregate model (Issue #729)
-    state
-        .service
+    embedding_service
         .queue_for_embedding(&node.id)
         .await
         .map_err(|e| {
@@ -302,10 +297,12 @@ pub async fn update_root_embedding(
 /// ```
 #[tauri::command]
 pub async fn on_root_closed(
-    state: State<'_, EmbeddingState>,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     root_id: String,
 ) -> Result<(), CommandError> {
+    let (embedding_service, _) = services.embedding_state().await?;
+    let node_service = services.node_service().await?;
+
     // Get the node from the database
     let node = node_service
         .get_node(&root_id)
@@ -320,8 +317,7 @@ pub async fn on_root_closed(
         .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?;
 
     // Queue node's root for embedding via root-aggregate model (Issue #729)
-    state
-        .service
+    embedding_service
         .queue_for_embedding(&node.id)
         .await
         .map_err(|e| {
@@ -362,10 +358,12 @@ pub async fn on_root_closed(
 /// ```
 #[tauri::command]
 pub async fn on_root_idle(
-    state: State<'_, EmbeddingState>,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     root_id: String,
 ) -> Result<bool, CommandError> {
+    let (embedding_service, _) = services.embedding_state().await?;
+    let node_service = services.node_service().await?;
+
     // Get the node from the database
     let node = node_service
         .get_node(&root_id)
@@ -380,8 +378,7 @@ pub async fn on_root_idle(
         .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?;
 
     // Queue node's root for embedding via root-aggregate model (Issue #729)
-    state
-        .service
+    embedding_service
         .queue_for_embedding(&node.id)
         .await
         .map_err(|e| {
@@ -418,9 +415,11 @@ pub async fn on_root_idle(
 /// console.log('Sync triggered (processing in background)');
 /// ```
 #[tauri::command]
-pub async fn sync_embeddings(state: State<'_, EmbeddingState>) -> Result<(), CommandError> {
+pub async fn sync_embeddings(services: State<'_, AppServices>) -> Result<(), CommandError> {
+    let (_, processor) = services.embedding_state().await?;
+
     // Trigger batch embedding (fire-and-forget, wakes the processor)
-    state.processor.trigger_batch_embed().map_err(|e| {
+    processor.trigger_batch_embed().map_err(|e| {
         command_error_with_details(
             format!("Failed to trigger sync: {}", e),
             "EMBEDDING_ERROR",
@@ -446,9 +445,9 @@ pub async fn sync_embeddings(state: State<'_, EmbeddingState>) -> Result<(), Com
 /// // Display badge: "${count} topics need indexing"
 /// ```
 #[tauri::command]
-pub async fn get_stale_root_count(
-    node_service: State<'_, NodeService>,
-) -> Result<usize, CommandError> {
+pub async fn get_stale_root_count(services: State<'_, AppServices>) -> Result<usize, CommandError> {
+    let node_service = services.node_service().await?;
+
     // Use new embedding table model (Issue #729)
     // Pass debounce_secs=0 to get ALL stale embeddings (for reporting purposes)
     // This shows the user the true count of pending work, including items still in debounce window
@@ -491,10 +490,12 @@ pub async fn get_stale_root_count(
 /// ```
 #[tauri::command]
 pub async fn batch_generate_embeddings(
-    state: State<'_, EmbeddingState>,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     root_ids: Vec<String>,
 ) -> Result<BatchEmbeddingResult, CommandError> {
+    let (embedding_service, _) = services.embedding_state().await?;
+    let node_service = services.node_service().await?;
+
     let mut success_count = 0;
     let mut failed_embeddings = Vec::new();
 
@@ -507,7 +508,7 @@ pub async fn batch_generate_embeddings(
         {
             Ok(Some(node)) => {
                 // Queue node's root for embedding via root-aggregate model (Issue #729)
-                match state.service.queue_for_embedding(&node.id).await {
+                match embedding_service.queue_for_embedding(&node.id).await {
                     Ok(_) => {
                         success_count += 1;
                         tracing::debug!("Queued embedding for node: {}", root_id);
diff --git a/packages/desktop-app/src-tauri/src/commands/import.rs b/packages/desktop-app/src-tauri/src/commands/import.rs
index 3296dd7ba..e4b5e7147 100644
--- a/packages/desktop-app/src-tauri/src/commands/import.rs
+++ b/packages/desktop-app/src-tauri/src/commands/import.rs
@@ -27,6 +27,7 @@
 //!
 //! This returns immediately to the UI while heavy database work happens in background.
 
+use crate::app_services::AppServices;
 use nodespace_core::mcp::handlers::markdown::{
     prepare_nodes_from_markdown, transform_links_in_nodes_with_mentions, PreparedNode,
 };
@@ -362,10 +363,11 @@ fn to_title_case(s: &str) -> String {
 /// parses markdown, and creates nodes using bulk_create_hierarchy.
 #[tauri::command]
 pub async fn import_markdown_file(
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     file_path: String,
     options: Option<ImportOptions>,
 ) -> Result<FileImportResult, String> {
+    let node_service = services.node_service().await.map_err(|e| e.message)?;
     let path = PathBuf::from(&file_path);
     let options = options.unwrap_or_default();
 
@@ -443,7 +445,6 @@ pub async fn import_markdown_file(
         Ok((root_id, nodes_created)) => {
             // For single file import, do collection assignment immediately
             if let Some(ref coll) = collection {
-                use nodespace_core::services::CollectionService;
                 let collection_service =
                     CollectionService::new(node_service.store(), &node_service);
                 if let Err(e) = collection_service
@@ -504,10 +505,11 @@ pub async fn import_markdown_file(
 #[tauri::command]
 pub async fn import_markdown_files(
     app: AppHandle,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     file_paths: Vec<String>,
     options: Option<ImportOptions>,
 ) -> Result<BatchImportResult, String> {
+    let node_service = services.node_service().await.map_err(|e| e.message)?;
     let start = std::time::Instant::now();
     let total_files = file_paths.len();
     let options = options.unwrap_or_default();
@@ -786,7 +788,7 @@ pub async fn import_markdown_files(
     // 2. Computing lengths/counts here avoids borrowing issues in the async context
     // 3. Cloning Arc/handles is cheap and allows the original references to be dropped
     let store = Arc::clone(node_service.store());
-    let node_service_clone = node_service.inner().clone();
+    let node_service_clone = (*node_service).clone();
     let app_clone = app.clone();
     let total_files_clone = total_files;
     let successful_files_clone = prepared_files.len();
@@ -1009,7 +1011,7 @@ pub async fn import_markdown_files(
 #[tauri::command]
 pub async fn import_markdown_directory(
     app: AppHandle,
-    node_service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     directory_path: String,
     options: Option<ImportOptions>,
 ) -> Result<BatchImportResult, String> {
@@ -1047,7 +1049,7 @@ pub async fn import_markdown_directory(
     }
 
     // Import all files
-    import_markdown_files(app, node_service, md_files, Some(import_options)).await
+    import_markdown_files(app, services, md_files, Some(import_options)).await
 }
 
 /// Recursively collect markdown files from a directory with exclusion patterns
diff --git a/packages/desktop-app/src-tauri/src/commands/nodes.rs b/packages/desktop-app/src-tauri/src/commands/nodes.rs
index 4db203928..31c27b01f 100644
--- a/packages/desktop-app/src-tauri/src/commands/nodes.rs
+++ b/packages/desktop-app/src-tauri/src/commands/nodes.rs
@@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
 use serde_json::Value;
 use tauri::State;
 
+use crate::app_services::AppServices;
 use crate::constants::TAURI_CLIENT_ID;
 
 /// Input for creating a node - timestamps generated server-side
@@ -154,9 +155,10 @@ fn nodes_to_typed_values(nodes: Vec<Node>) -> Result<Vec<Value>, CommandError> {
 /// ```
 #[tauri::command]
 pub async fn create_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node: CreateNodeInput,
 ) -> Result<String, CommandError> {
+    let service = services.node_service().await?;
     validate_node_type(&node.node_type, &service).await?;
 
     // Use NodeService to create node with business rule enforcement
@@ -228,9 +230,10 @@ pub struct SaveNodeWithParentInput {
 /// ```
 #[tauri::command]
 pub async fn create_root_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     input: CreateRootNodeInput,
 ) -> Result<String, CommandError> {
+    let service = services.node_service().await?;
     validate_node_type(&input.node_type, &service).await?;
 
     // Create root node with NodeService (parent_id = None means root)
@@ -280,10 +283,11 @@ pub async fn create_root_node(
 /// ```
 #[tauri::command]
 pub async fn create_node_mention(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     mentioning_node_id: String,
     mentioned_node_id: String,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
     service
         .with_client(TAURI_CLIENT_ID)
         .create_mention(&mentioning_node_id, &mentioned_node_id)
@@ -316,9 +320,10 @@ pub async fn create_node_mention(
 /// ```
 #[tauri::command]
 pub async fn get_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     id: String,
 ) -> Result<Option<Value>, CommandError> {
+    let service = services.node_service().await?;
     let node = service
         .with_client(TAURI_CLIENT_ID)
         .get_node(&id)
@@ -364,11 +369,12 @@ pub async fn get_node(
 /// ```
 #[tauri::command]
 pub async fn update_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     id: String,
     version: i64,
     update: NodeUpdate,
 ) -> Result<Value, CommandError> {
+    let service = services.node_service().await?;
     // DEBUG: Log incoming update to trace @mention persistence issue
     let content_preview = update.content.as_ref().map(|c| {
         if c.len() > 50 {
@@ -433,10 +439,11 @@ pub async fn update_node(
 /// ```
 #[tauri::command]
 pub async fn delete_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     id: String,
     version: i64,
 ) -> Result<nodespace_core::models::DeleteResult, CommandError> {
+    let service = services.node_service().await?;
     service
         .with_client(TAURI_CLIENT_ID)
         .delete_node(&id, version)
@@ -478,12 +485,13 @@ pub async fn delete_node(
 /// ```
 #[tauri::command]
 pub async fn move_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
     version: i64,
     new_parent_id: Option<String>,
     insert_after_node_id: Option<String>,
 ) -> Result<Value, CommandError> {
+    let service = services.node_service().await?;
     let node = service
         .with_client(TAURI_CLIENT_ID)
         .move_node(
@@ -525,11 +533,12 @@ pub async fn move_node(
 /// ```
 #[tauri::command]
 pub async fn reorder_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
     version: i64,
     insert_after_node_id: Option<String>,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
     service
         .with_client(TAURI_CLIENT_ID)
         .reorder_node(&node_id, version, insert_after_node_id.as_deref())
@@ -569,9 +578,10 @@ pub async fn reorder_node(
 /// ```
 #[tauri::command]
 pub async fn get_children(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     parent_id: String,
 ) -> Result<Vec<Value>, CommandError> {
+    let service = services.node_service().await?;
     let nodes = service
         .with_client(TAURI_CLIENT_ID)
         .get_children(&parent_id)
@@ -618,9 +628,10 @@ pub async fn get_children(
 /// ```
 #[tauri::command]
 pub async fn get_children_tree(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     parent_id: String,
 ) -> Result<serde_json::Value, CommandError> {
+    let service = services.node_service().await?;
     service
         .get_children_tree(&parent_id)
         .await
@@ -652,9 +663,10 @@ pub async fn get_children_tree(
 /// ```
 #[tauri::command]
 pub async fn get_nodes_by_root_id(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     root_id: String,
 ) -> Result<Vec<Value>, CommandError> {
+    let service = services.node_service().await?;
     // Phase 5 (Issue #511): Redirect to get_children (graph-native)
     let nodes = service
         .with_client(TAURI_CLIENT_ID)
@@ -708,9 +720,10 @@ pub async fn get_nodes_by_root_id(
 /// ```
 #[tauri::command]
 pub async fn query_nodes_simple(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     query: NodeQuery,
 ) -> Result<Vec<Value>, CommandError> {
+    let service = services.node_service().await?;
     let nodes = service
         .with_client(TAURI_CLIENT_ID)
         .query_nodes_simple(query)
@@ -754,10 +767,11 @@ pub async fn query_nodes_simple(
 /// ```
 #[tauri::command]
 pub async fn mention_autocomplete(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     query: String,
     limit: Option<usize>,
 ) -> Result<Vec<Value>, CommandError> {
+    let service = services.node_service().await?;
     let nodes = service
         .mention_autocomplete(&query, limit)
         .await
@@ -814,9 +828,10 @@ pub async fn mention_autocomplete(
 /// to enforce business rules within the transaction semantics (tracked in follow-up issue).
 #[tauri::command]
 pub async fn save_node_with_parent(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     input: SaveNodeWithParentInput,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
     validate_node_type(&input.node_type, &service).await?;
 
     // Use single-transaction upsert method (bypasses NodeOperations for transactional reasons)
@@ -854,9 +869,10 @@ pub async fn save_node_with_parent(
 /// ```
 #[tauri::command]
 pub async fn get_outgoing_mentions(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
 ) -> Result<Vec<String>, CommandError> {
+    let service = services.node_service().await?;
     service
         .with_client(TAURI_CLIENT_ID)
         .get_mentions(&node_id)
@@ -885,9 +901,10 @@ pub async fn get_outgoing_mentions(
 /// ```
 #[tauri::command]
 pub async fn get_incoming_mentions(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
 ) -> Result<Vec<String>, CommandError> {
+    let service = services.node_service().await?;
     service
         .with_client(TAURI_CLIENT_ID)
         .get_mentioned_by(&node_id)
@@ -927,9 +944,10 @@ pub async fn get_incoming_mentions(
 /// ```
 #[tauri::command]
 pub async fn get_mentioning_roots(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     node_id: String,
 ) -> Result<Vec<NodeReference>, CommandError> {
+    let service = services.node_service().await?;
     service
         .get_mentioning_containers(&node_id)
         .await
@@ -972,11 +990,12 @@ pub async fn get_mentioning_roots(
 /// ```
 #[tauri::command]
 pub async fn update_task_node(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     id: String,
     version: i64,
     update: models::TaskNodeUpdate,
 ) -> Result<Value, CommandError> {
+    let service = services.node_service().await?;
     let task = service
         .update_task_node(&id, version, update)
         .await
@@ -1012,10 +1031,11 @@ pub async fn update_task_node(
 /// ```
 #[tauri::command]
 pub async fn delete_node_mention(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     mentioning_node_id: String,
     mentioned_node_id: String,
 ) -> Result<(), CommandError> {
+    let service = services.node_service().await?;
     service
         .with_client(TAURI_CLIENT_ID)
         .remove_mention(&mentioning_node_id, &mentioned_node_id)
diff --git a/packages/desktop-app/src-tauri/src/commands/schemas.rs b/packages/desktop-app/src-tauri/src/commands/schemas.rs
index 709655353..14d205fbe 100644
--- a/packages/desktop-app/src-tauri/src/commands/schemas.rs
+++ b/packages/desktop-app/src-tauri/src/commands/schemas.rs
@@ -8,8 +8,9 @@
 //! - `get_all_schemas` - List all schema nodes (returns SchemaNode[] with typed fields)
 //! - `get_schema_definition` - Get a specific schema by ID (returns SchemaNode with typed fields)
 
+use crate::app_services::AppServices;
 use nodespace_core::services::NodeServiceError;
-use nodespace_core::{NodeQuery, NodeService, SchemaNode};
+use nodespace_core::{NodeQuery, SchemaNode};
 use serde::Serialize;
 use tauri::State;
 
@@ -43,8 +44,14 @@ impl From<NodeServiceError> for CommandError {
 /// * `Err(CommandError)` - Error if retrieval fails
 #[tauri::command]
 pub async fn get_all_schemas(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
 ) -> Result<Vec<SchemaNode>, CommandError> {
+    let service = services.node_service().await.map_err(|e| CommandError {
+        message: e.message,
+        code: e.code,
+        details: e.details,
+    })?;
+
     // Query all schema nodes and wrap in SchemaNode for typed serialization
     let query = NodeQuery {
         node_type: Some("schema".to_string()),
@@ -77,9 +84,15 @@ pub async fn get_all_schemas(
 /// * `Err(CommandError)` - Error if schema not found
 #[tauri::command]
 pub async fn get_schema_definition(
-    service: State<'_, NodeService>,
+    services: State<'_, AppServices>,
     schema_id: String,
 ) -> Result<SchemaNode, CommandError> {
+    let service = services.node_service().await.map_err(|e| CommandError {
+        message: e.message,
+        code: e.code,
+        details: e.details,
+    })?;
+
     let schema_node = service
         .get_schema_node(&schema_id)
         .await
diff --git a/packages/desktop-app/src-tauri/src/commands/settings.rs b/packages/desktop-app/src-tauri/src/commands/settings.rs
index 48d4c78fe..9b0451eab 100644
--- a/packages/desktop-app/src-tauri/src/commands/settings.rs
+++ b/packages/desktop-app/src-tauri/src/commands/settings.rs
@@ -2,8 +2,9 @@
 //!
 //! These commands expose the preferences system to the frontend.
 //! Display settings (theme, markdown rendering) take effect immediately.
-//! Database settings require an app restart.
+//! Database settings now hot-swap services without requiring a restart.
 
+use crate::app_services::AppServices;
 use tauri::{AppHandle, Manager};
 
 /// Settings response sent to the frontend
@@ -25,11 +26,22 @@ pub struct DisplaySettingsResponse {
     pub theme: String,
 }
 
+/// Result of a database switch operation
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DatabaseSwitchResult {
+    pub new_path: String,
+    pub success: bool,
+}
+
 /// Get current app settings for the Settings UI
 #[tauri::command]
-pub async fn get_settings(app: AppHandle) -> Result<SettingsResponse, String> {
+pub async fn get_settings(
+    app: AppHandle,
+    services: tauri::State<'_, AppServices>,
+) -> Result<SettingsResponse, String> {
     let prefs = crate::preferences::load_preferences(&app).await?;
-    let config: tauri::State<crate::config::AppConfig> = app.state();
+    let config = services.config().await.map_err(|e| e.message)?;
 
     Ok(SettingsResponse {
         active_database_path: config.database_path.to_string_lossy().to_string(),
@@ -84,18 +96,13 @@ pub async fn update_display_settings(
     Ok(())
 }
 
-/// Result of selecting a new database location
-#[derive(serde::Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct PendingDatabaseChange {
-    pub new_path: String,
-    pub requires_restart: bool,
-}
-
-/// Open native folder picker and save chosen database path to preferences.
-/// Does NOT reinitialize services — app must restart for new database.
+/// Open native folder picker, save chosen database path, and hot-swap services.
 #[tauri::command]
-pub async fn select_new_database(app: tauri::AppHandle) -> Result<PendingDatabaseChange, String> {
+pub async fn select_new_database(
+    app: tauri::AppHandle,
+    services: tauri::State<'_, AppServices>,
+) -> Result<DatabaseSwitchResult, String> {
+    use tauri::Emitter;
     use tauri_plugin_dialog::{DialogExt, FilePath};
 
     let folder = app
@@ -115,9 +122,16 @@ pub async fn select_new_database(app: tauri::AppHandle) -> Result<PendingDatabas
     prefs.database_path = Some(db_path.clone());
     crate::preferences::save_preferences(&app, &prefs).await?;
 
-    Ok(PendingDatabaseChange {
-        new_path: db_path.to_string_lossy().to_string(),
-        requires_restart: true,
+    // Hot-swap database services
+    switch_database_services(&app, &services, db_path.clone()).await?;
+
+    // Emit database-changed event to frontend
+    let path_str = db_path.to_string_lossy().to_string();
+    let _ = app.emit("database-changed", &path_str);
+
+    Ok(DatabaseSwitchResult {
+        new_path: path_str,
+        success: true,
     })
 }
 
@@ -135,13 +149,149 @@ pub fn restart_app(app: tauri::AppHandle) {
     app.restart();
 }
 
-/// Reset database path to default. Requires restart.
+/// Reset database path to default and hot-swap to the default database.
 #[tauri::command]
-pub async fn reset_database_to_default(app: tauri::AppHandle) -> Result<String, String> {
+pub async fn reset_database_to_default(
+    app: tauri::AppHandle,
+    services: tauri::State<'_, AppServices>,
+) -> Result<String, String> {
+    use tauri::Emitter;
+
     let mut prefs = crate::preferences::load_preferences(&app).await?;
     prefs.database_path = None;
     crate::preferences::save_preferences(&app, &prefs).await?;
 
     let default_path = crate::preferences::get_default_database_path()?;
-    Ok(default_path.to_string_lossy().to_string())
+
+    // Hot-swap to default database
+    switch_database_services(&app, &services, default_path.clone()).await?;
+
+    let path_str = default_path.to_string_lossy().to_string();
+    let _ = app.emit("database-changed", &path_str);
+
+    Ok(path_str)
+}
+
+/// Hot-swap database services: create new store, node service, and embeddings,
+/// then atomically replace the running services and restart background tasks.
+async fn switch_database_services(
+    app: &AppHandle,
+    services: &AppServices,
+    new_db_path: std::path::PathBuf,
+) -> Result<(), String> {
+    use crate::app_services::EmbeddingState;
+    use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService};
+    use nodespace_core::{NodeService, SurrealStore};
+    use nodespace_nlp_engine::{EmbeddingConfig, EmbeddingService};
+    use std::sync::Arc;
+
+    // Get current config for model path etc.
+    let old_config = services.config().await.map_err(|e| e.message)?;
+
+    tracing::info!("🔧 Switching database to: {:?}", new_db_path);
+
+    // Ensure directory exists
+    if let Some(parent) = new_db_path.parent() {
+        tokio::fs::create_dir_all(parent)
+            .await
+            .map_err(|e| format!("Failed to create database directory: {}", e))?;
+    }
+
+    // Create new store
+    let mut store = Arc::new(
+        SurrealStore::new(new_db_path.clone())
+            .await
+            .map_err(|e| format!("Failed to initialize new database: {}", e))?,
+    );
+
+    // Create new NodeService
+    let mut node_service = NodeService::new(&mut store)
+        .await
+        .map_err(|e| format!("Failed to initialize node service: {}", e))?;
+
+    // Create new embedding engine
+    let embedding_config = EmbeddingConfig {
+        model_path: Some(old_config.model_path.clone()),
+        ..Default::default()
+    };
+
+    let embedding_state = match EmbeddingService::new(embedding_config) {
+        Ok(mut nlp_engine) => match nlp_engine.initialize() {
+            Ok(()) => {
+                let nlp_arc = Arc::new(nlp_engine);
+                let emb_service = NodeEmbeddingService::new(nlp_arc.clone(), store.clone());
+                let emb_service_arc = Arc::new(emb_service);
+                let processor = EmbeddingProcessor::new(emb_service_arc.clone())
+                    .map_err(|e| format!("Failed to init embedding processor: {}", e))?;
+                node_service.set_embedding_waker(processor.waker());
+                processor.wake();
+                let processor_arc = Arc::new(processor);
+                Some(EmbeddingState {
+                    service: emb_service_arc,
+                    processor: processor_arc,
+                })
+            }
+            Err(e) => {
+                tracing::warn!("Failed to load NLP model during switch: {}", e);
+                None
+            }
+        },
+        Err(e) => {
+            tracing::warn!("Failed to create NLP engine during switch: {}", e);
+            None
+        }
+    };
+
+    let node_service_arc = Arc::new(node_service);
+
+    // Build new config
+    let new_config = crate::config::AppConfig {
+        database_path: new_db_path,
+        model_path: old_config.model_path,
+        mcp_port: old_config.mcp_port,
+        tauri_client_id: old_config.tauri_client_id.clone(),
+    };
+
+    // Create new session token
+    let shutdown_token: tauri::State<crate::ShutdownToken> = app.state();
+    let new_session_token = shutdown_token.child_token();
+
+    // Hot-swap services
+    let embedding_service_arc = embedding_state.as_ref().map(|es| es.service.clone());
+    services
+        .switch_database(
+            store.clone(),
+            node_service_arc.clone(),
+            embedding_state,
+            new_config.clone(),
+            new_session_token.clone(),
+        )
+        .await;
+
+    // Restart background services with new session token
+    if let Some(emb_svc) = embedding_service_arc {
+        if let Err(e) = crate::initialize_mcp_server(
+            app.clone(),
+            node_service_arc.clone(),
+            emb_svc,
+            new_session_token.clone(),
+        ) {
+            tracing::error!("Failed to restart MCP server after switch: {}", e);
+        }
+    }
+
+    if let Err(e) = crate::initialize_domain_event_forwarder(
+        app.clone(),
+        node_service_arc.clone(),
+        new_config.tauri_client_id.clone(),
+        new_session_token,
+    ) {
+        tracing::error!(
+            "Failed to restart domain event forwarder after switch: {}",
+            e
+        );
+    }
+
+    tracing::info!("✅ Database switch complete");
+    Ok(())
 }
diff --git a/packages/desktop-app/src-tauri/src/lib.rs b/packages/desktop-app/src-tauri/src/lib.rs
index c04f5ce0f..1761ffa56 100644
--- a/packages/desktop-app/src-tauri/src/lib.rs
+++ b/packages/desktop-app/src-tauri/src/lib.rs
@@ -10,6 +10,9 @@ pub mod constants;
 // Runtime application configuration
 pub mod config;
 
+// Centralized services container (Issue #894)
+pub mod app_services;
+
 // MCP Tauri integration (wraps core MCP with event emissions)
 pub mod mcp_integration;
 
@@ -84,41 +87,27 @@ pub fn initialize_domain_event_forwarder(
     Ok(())
 }
 
-/// Initialize MCP server with shared services from Tauri state
+/// Initialize MCP server with shared services
 ///
-/// This must be called AFTER the database is initialized and services
-/// are available in Tauri's managed state. It retrieves the shared NodeService
-/// and NodeEmbeddingService and spawns the MCP server task with them,
-/// ensuring MCP and Tauri commands operate on the same database.
+/// Takes Arc<NodeService> and Arc<NodeEmbeddingService> directly rather than
+/// reading from Tauri state. This supports hot-swapping via AppServices.
 ///
 /// The `cancel_token` is used for graceful shutdown - when cancelled, the MCP
 /// server task will be aborted before the Tokio runtime drops.
-///
-/// As of Issue #715, uses McpServerService from nodespace-core for managed lifecycle.
 pub fn initialize_mcp_server(
     app: tauri::AppHandle,
+    node_service: std::sync::Arc<nodespace_core::NodeService>,
+    embedding_service: std::sync::Arc<nodespace_core::services::NodeEmbeddingService>,
     cancel_token: tokio_util::sync::CancellationToken,
 ) -> anyhow::Result<()> {
-    use crate::commands::embeddings::EmbeddingState;
     use futures::FutureExt;
-    use nodespace_core::NodeService;
-    use std::sync::Arc;
-    use tauri::Manager;
 
     tracing::info!("🔧 Initializing MCP server service...");
 
-    // Get shared services from Tauri state
-    // This ensures MCP uses the same database and embedding service as Tauri commands
-    let node_service: tauri::State<NodeService> = app.state();
-    let node_service_arc = Arc::new(node_service.inner().clone());
-
-    let embedding_state: tauri::State<EmbeddingState> = app.state();
-    let embedding_service_arc = embedding_state.service.clone();
-
     // Create MCP service with Tauri event callback
     let (mcp_service, callback) = mcp_integration::create_mcp_service_with_events(
-        node_service_arc,
-        embedding_service_arc,
+        node_service,
+        embedding_service,
         app.clone(),
     );
 
@@ -127,16 +116,10 @@ pub fn initialize_mcp_server(
         mcp_service.port()
     );
 
-    // Register MCP service as managed state for potential future access
-    app.manage(mcp_service.clone());
-
     // Spawn MCP server task with Tauri event emissions
     // Uses panic protection to prevent silent background task failures
     // Monitors cancel_token for graceful shutdown before runtime drops
     tauri::async_runtime::spawn(async move {
-        // Use FutureExt::catch_unwind for proper async panic catching
-        // This avoids the deadlock risk of using block_on inside an async task
-        // AssertUnwindSafe is needed because services contain non-UnwindSafe types
         let result = std::panic::AssertUnwindSafe(async {
             tokio::select! {
                 res = mcp_service.start_with_callback(callback) => res,
@@ -291,9 +274,9 @@ pub fn run() {
             // when spawning background tasks (MCP server, domain event forwarder)
             app.manage(shutdown_token_for_setup);
 
-            // Note: MCP server initialization is deferred until database is initialized
-            // See commands/db.rs::init_services() which calls initialize_mcp_server()
-            // after NodeService is available in Tauri state
+            // Register AppServices container as managed state (Issue #894)
+            // Services are populated later via commands/db.rs::init_services()
+            app.manage(app_services::AppServices::new());
 
             Ok(())
         })
@@ -406,54 +389,35 @@ pub fn run() {
         .expect("error while building tauri application");
 
     // Run with event handler for graceful shutdown
-    // This allows proper cleanup of Metal/GPU resources and background tasks before exit
-    //
-    // Note: On macOS, RunEvent::ExitRequested may not fire reliably (Tauri issue #9198)
-    // We handle cleanup in multiple places to ensure resources are released:
-    // 1. WindowEvent::CloseRequested - when user clicks X or Cmd+Q
-    // 2. RunEvent::ExitRequested - when app is about to exit
-    // 3. RunEvent::Exit - final cleanup before process termination
-    //
-    // The shutdown_token is cancelled to signal background tasks (MCP server,
-    // domain event forwarder) to exit their loops before the Tokio runtime drops.
     let shutdown_token_for_events = shutdown_token.clone();
-    app.run(move |app_handle, event| {
-        match event {
-            RunEvent::WindowEvent {
-                label,
-                event: tauri::WindowEvent::CloseRequested { .. },
-                ..
-            } => {
-                // Window close requested - most reliable cleanup point on macOS
-                tracing::info!(
-                    "Window '{}' close requested, performing graceful shutdown...",
-                    label
-                );
-                graceful_shutdown(app_handle);
-            }
-            RunEvent::ExitRequested { code, .. } => {
-                // App exit requested - may not fire on macOS (Tauri issue #9198)
-                tracing::info!(
-                    "App exit requested (code: {:?}), performing graceful shutdown...",
-                    code
-                );
-                graceful_shutdown(app_handle);
-            }
-            RunEvent::Exit => {
-                // Final exit - ensure shutdown signal is sent (idempotent)
-                tracing::info!("App exiting, ensuring shutdown signal sent...");
-                shutdown_token_for_events.cancel();
-            }
-            _ => {}
+    app.run(move |app_handle, event| match event {
+        RunEvent::WindowEvent {
+            label,
+            event: tauri::WindowEvent::CloseRequested { .. },
+            ..
+        } => {
+            tracing::info!(
+                "Window '{}' close requested, performing graceful shutdown...",
+                label
+            );
+            graceful_shutdown(app_handle);
+        }
+        RunEvent::ExitRequested { code, .. } => {
+            tracing::info!(
+                "App exit requested (code: {:?}), performing graceful shutdown...",
+                code
+            );
+            graceful_shutdown(app_handle);
         }
+        RunEvent::Exit => {
+            tracing::info!("App exiting, ensuring shutdown signal sent...");
+            shutdown_token_for_events.cancel();
+        }
+        _ => {}
     });
 }
 
 /// Perform graceful shutdown: cancel background tasks, wait for them to exit, then release GPU.
-///
-/// This is the canonical shutdown sequence used before any process exit (quit, restart, etc.).
-/// The 50ms pause lets background tasks (MCP server, domain event forwarder) exit their loops
-/// before we release GPU resources they may still reference.
 pub(crate) fn graceful_shutdown(app_handle: &tauri::AppHandle) {
     use tauri::Manager;
 
@@ -467,26 +431,18 @@ pub(crate) fn graceful_shutdown(app_handle: &tauri::AppHandle) {
 
 /// Release GPU resources (Metal context and backend) to prevent SIGABRT crash on exit.
 ///
-/// This must be called before the app exits to properly clean up Metal/GPU
-/// resources. The crash occurs when ggml_metal_rsets_free is called during
-/// static destruction (__cxa_finalize_ranges) while resources are still in use.
-///
-/// Cleanup order is critical:
-/// 1. Release LlamaState (context + model) - frees Metal residency sets
-/// 2. Release global LLAMA_BACKEND - frees the Metal backend itself
-///
-/// This prevents __cxa_finalize_ranges from encountering Metal resources during
-/// static destruction, which would cause SIGABRT in production builds.
+/// Now accesses embedding state through AppServices container.
 pub(crate) fn release_gpu_resources(app_handle: &tauri::AppHandle) {
     use tauri::Manager;
 
-    if let Some(embedding_state) =
-        app_handle.try_state::<crate::commands::embeddings::EmbeddingState>()
-    {
-        tracing::info!("Releasing GPU context to prevent Metal crash...");
-        // Step 1: Release the LlamaState (context + model) which holds Metal residency sets
-        embedding_state.service.nlp_engine().release_gpu_context();
-        tracing::info!("GPU context released successfully");
+    if let Some(services) = app_handle.try_state::<app_services::AppServices>() {
+        // Use blocking approach since this is called from sync shutdown code
+        let services_clone = services.inner();
+        // We need to block on the async release - this is safe during shutdown
+        // since the tokio runtime is still alive at this point
+        tauri::async_runtime::block_on(async {
+            services_clone.release_gpu_resources().await;
+        });
     }
 
     // Step 2: Release the global llama backend itself
diff --git a/packages/desktop-app/src/lib/components/layout/app-shell.svelte b/packages/desktop-app/src/lib/components/layout/app-shell.svelte
index 6c8fd4108..73e926ea0 100644
--- a/packages/desktop-app/src/lib/components/layout/app-shell.svelte
+++ b/packages/desktop-app/src/lib/components/layout/app-shell.svelte
@@ -158,6 +158,7 @@
     let unlistenStatusBar: Promise<() => void> | null = null;
     let unlistenImport: Promise<() => void> | null = null;
     let unlistenDatabase: Promise<() => void> | null = null;
+    let unlistenDatabaseChanged: Promise<() => void> | null = null;
     let unlistenSettings: Promise<() => void> | null = null;
     let cleanupMCP: (() => Promise<void>) | null = null;
     let staleNodesInterval: ReturnType<typeof setInterval> | null = null;
@@ -278,19 +279,14 @@
         }
       });
 
-      // Listen for database selection from menu
+      // Listen for database selection from menu (hot-swap, no restart needed)
       unlistenDatabase = listen('menu-select-database', async () => {
         try {
-          const result = await invoke<{ newPath: string; requiresRestart: boolean }>(
+          const result = await invoke<{ newPath: string; success: boolean }>(
             'select_new_database'
           );
-          if (result.requiresRestart) {
-            const confirmed = window.confirm(
-              `Database location changed to:\n${result.newPath}\n\nNodeSpace needs to restart to use the new database. Restart now?`
-            );
-            if (confirmed) {
-              await invoke('restart_app');
-            }
+          if (result.success) {
+            log.info('Database switched to:', result.newPath);
           }
         } catch (err) {
           if (err !== 'No folder selected') {
@@ -299,6 +295,12 @@
         }
       });
 
+      // Listen for database-changed event after hot-swap (Issue #894)
+      // Frontend views will refresh automatically via domain event forwarder restart
+      unlistenDatabaseChanged = listen('database-changed', (event) => {
+        log.info('Database changed to:', event.payload);
+      });
+
       // Listen for settings menu — open or focus settings tab
       unlistenSettings = listen('menu-open-settings', () => {
         const state = get(tabState);
@@ -449,6 +451,9 @@
       if (unlistenDatabase) {
         (await unlistenDatabase)();
       }
+      if (unlistenDatabaseChanged) {
+        (await unlistenDatabaseChanged)();
+      }
       if (unlistenSettings) {
         (await unlistenSettings)();
       }
diff --git a/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte b/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte
index 0f8bea4d8..20e6c5e56 100644
--- a/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte
+++ b/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte
@@ -19,16 +19,9 @@
     <div class="setting-actions">
         <button class="btn btn-secondary" onclick={async () => {
             try {
-                const result = await invoke<{ newPath: string; requiresRestart: boolean }>('select_new_database');
-                if (result.requiresRestart) {
-                    const confirmed = window.confirm(
-                        `Database location changed to:\n${result.newPath}\n\nNodeSpace needs to restart to use the new database. Restart now?`
-                    );
-                    if (confirmed) {
-                        await invoke('restart_app');
-                    } else {
-                        await loadSettings();
-                    }
+                const result = await invoke<{ newPath: string; success: boolean }>('select_new_database');
+                if (result.success) {
+                    await loadSettings();
                 }
             } catch (err) {
                 if (err !== 'No folder selected') {
@@ -41,15 +34,8 @@
 
         <button class="btn btn-outline" onclick={async () => {
             try {
-                const defaultPath = await invoke<string>('reset_database_to_default');
-                const confirmed = window.confirm(
-                    `Database will be reset to default location:\n${defaultPath}\n\nNodeSpace needs to restart. Restart now?`
-                );
-                if (confirmed) {
-                    await invoke('restart_app');
-                } else {
-                    await loadSettings();
-                }
+                await invoke<string>('reset_database_to_default');
+                await loadSettings();
             } catch (err) {
                 log.error('Failed to reset database:', err);
             }

From df5f293e90c6c7522ab73edb1052b4c8b6fb37a6 Mon Sep 17 00:00:00 2001
From: Michael Libio <malibio@gmail.com>
Date: Mon, 23 Feb 2026 13:55:26 +0100
Subject: [PATCH 2/2] Address PR #899 review: extract shared service init,
 tiered NLP, MCP without embeddings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three review findings addressed:

1. Tiered init inconsistency: init_services() now handles NLP failure
   gracefully (embedding_state = None) instead of killing the entire init,
   matching the pattern already used by switch_database_services().

2. Duplicated service creation: Extracted create_service_bundle() helper in
   db.rs that both init_services() and switch_database_services() call,
   preventing divergence between the two code paths.

3. MCP server skipped without embeddings: Made embedding_service optional
   throughout the MCP stack (McpServices, McpServerService, handlers).
   MCP now starts even when embeddings fail — node CRUD tools work;
   semantic search returns a graceful error message.

Co-Authored-By: Claude <noreply@anthropic.com>
---
 packages/core/src/mcp/handlers/initialize.rs  |  20 +-
 .../core/src/mcp/handlers/initialize_test.rs  |   4 +-
 packages/core/src/mcp/handlers/tools.rs       |  15 +-
 packages/core/src/mcp/handlers/tools_test.rs  |   5 +-
 packages/core/src/mcp/server.rs               |   6 +-
 .../core/src/services/mcp_server_service.rs   |  12 +-
 .../tests/mcp_handlers_integration_test.rs    |   4 +-
 .../desktop-app/src-tauri/src/commands/db.rs  | 200 +++++++++++-------
 .../src-tauri/src/commands/settings.rs        |  92 ++------
 packages/desktop-app/src-tauri/src/lib.rs     |   2 +-
 .../src-tauri/src/mcp_integration.rs          |   2 +-
 packages/dev-tools/src/bin/dev-mcp.rs         |   2 +-
 packages/dev-tools/src/bin/dev-proxy.rs       |   3 +-
 13 files changed, 191 insertions(+), 176 deletions(-)

diff --git a/packages/core/src/mcp/handlers/initialize.rs b/packages/core/src/mcp/handlers/initialize.rs
index b08bdf22f..1a695daf1 100644
--- a/packages/core/src/mcp/handlers/initialize.rs
+++ b/packages/core/src/mcp/handlers/initialize.rs
@@ -47,7 +47,7 @@ use std::sync::Arc;
 /// - Embedding model warmup fails
 pub async fn handle_initialize<C>(
     node_service: &Arc<NodeService<C>>,
-    embedding_service: &Arc<NodeEmbeddingService<C>>,
+    embedding_service: &Option<Arc<NodeEmbeddingService<C>>>,
     params: Value,
 ) -> Result<Value, MCPError>
 where
@@ -75,12 +75,20 @@ where
         )));
     }
 
-    // Warm up the embedding model to ensure fast first semantic search
+    // Warm up the embedding model to ensure fast first semantic search (if available).
     // This triggers model loading and Metal kernel compilation during handshake
-    // rather than on the first search query.
-    embedding_service.nlp_engine().warmup().map_err(|e| {
-        MCPError::internal_error(format!("Failed to warm up embedding model: {}", e))
-    })?;
+    // rather than on the first search query. When embeddings are unavailable,
+    // MCP still serves node CRUD — only semantic search is disabled.
+    if let Some(emb_svc) = embedding_service {
+        if let Err(e) = emb_svc.nlp_engine().warmup() {
+            tracing::warn!(
+                "Embedding model warmup failed (semantic search disabled): {}",
+                e
+            );
+        }
+    } else {
+        tracing::info!("MCP server starting without embeddings — semantic search disabled");
+    }
 
     // Fetch all available schemas to build dynamic instructions
     // Note: If schemas haven't been initialized yet (fresh database), fall back to
diff --git a/packages/core/src/mcp/handlers/initialize_test.rs b/packages/core/src/mcp/handlers/initialize_test.rs
index 40ac5aeef..5b2227a97 100644
--- a/packages/core/src/mcp/handlers/initialize_test.rs
+++ b/packages/core/src/mcp/handlers/initialize_test.rs
@@ -9,7 +9,7 @@ use std::sync::Arc;
 use tempfile::TempDir;
 
 // Helper to create test services (NodeService + NodeEmbeddingService)
-async fn create_test_services() -> (Arc<NodeService>, Arc<NodeEmbeddingService>, TempDir) {
+async fn create_test_services() -> (Arc<NodeService>, Option<Arc<NodeEmbeddingService>>, TempDir) {
     let temp_dir = TempDir::new().unwrap();
     let db_path = temp_dir.path().join("test.db");
     let mut store = Arc::new(SurrealStore::new(db_path).await.unwrap());
@@ -22,7 +22,7 @@ async fn create_test_services() -> (Arc<NodeService>, Arc<NodeEmbeddingService>,
 
     let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, store.clone()));
 
-    (node_service, embedding_service, temp_dir)
+    (node_service, Some(embedding_service), temp_dir)
 }
 
 #[tokio::test]
diff --git a/packages/core/src/mcp/handlers/tools.rs b/packages/core/src/mcp/handlers/tools.rs
index f84d3661c..7473a27a9 100644
--- a/packages/core/src/mcp/handlers/tools.rs
+++ b/packages/core/src/mcp/handlers/tools.rs
@@ -335,7 +335,7 @@ pub fn handle_tools_list(_params: Value) -> Result<Value, MCPError> {
 /// Returns JSON result with content array and isError flag per MCP spec
 pub async fn handle_tools_call<C>(
     node_service: &Arc<NodeService<C>>,
-    embedding_service: &Arc<NodeEmbeddingService<C>>,
+    embedding_service: &Option<Arc<NodeEmbeddingService<C>>>,
     params: Value,
 ) -> Result<Value, MCPError>
 where
@@ -384,10 +384,15 @@ where
         "get_nodes_batch" => nodes::handle_get_nodes_batch(node_service, arguments).await,
         "update_nodes_batch" => nodes::handle_update_nodes_batch(node_service, arguments).await,
 
-        // Search
-        "search_semantic" => {
-            search::handle_search_semantic(node_service, embedding_service, arguments).await
-        }
+        // Search — returns graceful error when embeddings are unavailable
+        "search_semantic" => match embedding_service {
+            Some(emb_svc) => {
+                search::handle_search_semantic(node_service, emb_svc, arguments).await
+            }
+            None => Err(MCPError::internal_error(
+                "Semantic search unavailable: embedding model failed to load. Node CRUD tools are still available.".to_string(),
+            )),
+        },
 
         // Discovery
         "search_tools" => handle_search_tools(arguments),
diff --git a/packages/core/src/mcp/handlers/tools_test.rs b/packages/core/src/mcp/handlers/tools_test.rs
index 597efcce2..567d1618b 100644
--- a/packages/core/src/mcp/handlers/tools_test.rs
+++ b/packages/core/src/mcp/handlers/tools_test.rs
@@ -136,7 +136,8 @@ mod async_integration_tests {
     use std::sync::Arc;
     use tempfile::TempDir;
 
-    async fn setup_test_services() -> (Arc<NodeService>, Arc<NodeEmbeddingService>, TempDir) {
+    async fn setup_test_services() -> (Arc<NodeService>, Option<Arc<NodeEmbeddingService>>, TempDir)
+    {
         let temp_dir = TempDir::new().unwrap();
         let db_path = temp_dir.path().join("test.db");
 
@@ -150,7 +151,7 @@ mod async_integration_tests {
 
         let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, store.clone()));
 
-        (node_service, embedding_service, temp_dir)
+        (node_service, Some(embedding_service), temp_dir)
     }
 
     #[tokio::test]
diff --git a/packages/core/src/mcp/server.rs b/packages/core/src/mcp/server.rs
index bf9437c24..1c25ee4d9 100644
--- a/packages/core/src/mcp/server.rs
+++ b/packages/core/src/mcp/server.rs
@@ -45,7 +45,9 @@ where
     C: surrealdb::Connection,
 {
     pub node_service: Arc<NodeService<C>>,
-    pub embedding_service: Arc<NodeEmbeddingService<C>>,
+    /// Optional: MCP still serves node CRUD without embeddings; semantic search
+    /// returns a graceful error when this is `None`.
+    pub embedding_service: Option<Arc<NodeEmbeddingService<C>>>,
 }
 
 /// Server state tracking initialization status
@@ -731,7 +733,7 @@ mod tests {
 
         McpServices {
             node_service,
-            embedding_service,
+            embedding_service: Some(embedding_service),
         }
     }
 
diff --git a/packages/core/src/services/mcp_server_service.rs b/packages/core/src/services/mcp_server_service.rs
index e75e93f95..ecb41a4a4 100644
--- a/packages/core/src/services/mcp_server_service.rs
+++ b/packages/core/src/services/mcp_server_service.rs
@@ -68,7 +68,9 @@ where
     C: surrealdb::Connection,
 {
     node_service: Arc<NodeService<C>>,
-    embedding_service: Arc<NodeEmbeddingService<C>>,
+    /// Optional: MCP still serves node CRUD without embeddings; semantic search
+    /// returns a graceful error when this is `None`.
+    embedding_service: Option<Arc<NodeEmbeddingService<C>>>,
     port: u16,
 }
 
@@ -81,7 +83,9 @@ where
     /// # Arguments
     ///
     /// * `node_service` - Shared NodeService instance for node operations
-    /// * `embedding_service` - Shared embedding service for semantic search
+    /// * `embedding_service` - Optional embedding service for semantic search.
+    ///   When `None`, MCP still serves node CRUD; semantic search returns a
+    ///   graceful error.
     /// * `port` - HTTP port to listen on (typically 3100)
     ///
     /// # Example
@@ -92,11 +96,11 @@ where
     ///     .and_then(|p| p.parse().ok())
     ///     .unwrap_or(3100);
     ///
-    /// let service = McpServerService::new(node_service, embedding_service, port);
+    /// let service = McpServerService::new(node_service, Some(embedding_service), port);
     /// ```
     pub fn new(
         node_service: Arc<NodeService<C>>,
-        embedding_service: Arc<NodeEmbeddingService<C>>,
+        embedding_service: Option<Arc<NodeEmbeddingService<C>>>,
         port: u16,
     ) -> Self {
         Self {
diff --git a/packages/core/tests/mcp_handlers_integration_test.rs b/packages/core/tests/mcp_handlers_integration_test.rs
index 1eaf354bc..233d7f131 100644
--- a/packages/core/tests/mcp_handlers_integration_test.rs
+++ b/packages/core/tests/mcp_handlers_integration_test.rs
@@ -34,7 +34,7 @@ async fn create_test_env() -> anyhow::Result<(Arc<NodeService>, TempDir)> {
 
 /// Test helper: Create a test environment with NodeService and NodeEmbeddingService
 async fn create_test_env_with_embedding(
-) -> anyhow::Result<(Arc<NodeService>, Arc<NodeEmbeddingService>, TempDir)> {
+) -> anyhow::Result<(Arc<NodeService>, Option<Arc<NodeEmbeddingService>>, TempDir)> {
     let temp_dir = TempDir::new()?;
     let db_path = temp_dir.path().join("test.db");
     let mut store = Arc::new(SurrealStore::new(db_path).await?);
@@ -47,7 +47,7 @@ async fn create_test_env_with_embedding(
 
     let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, store.clone()));
 
-    Ok((node_service, embedding_service, temp_dir))
+    Ok((node_service, Some(embedding_service), temp_dir))
 }
 
 // ============================================================================
diff --git a/packages/desktop-app/src-tauri/src/commands/db.rs b/packages/desktop-app/src-tauri/src/commands/db.rs
index 40ca4cf45..9bfe990da 100644
--- a/packages/desktop-app/src-tauri/src/commands/db.rs
+++ b/packages/desktop-app/src-tauri/src/commands/db.rs
@@ -8,7 +8,7 @@ use crate::app_services::{AppServices, EmbeddingState};
 use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService};
 use nodespace_core::{NodeService, SurrealStore};
 use nodespace_nlp_engine::{EmbeddingConfig, EmbeddingService};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use tauri::path::BaseDirectory;
 use tauri::{AppHandle, Manager};
@@ -16,6 +16,104 @@ use tokio::fs;
 
 use crate::constants::EMBEDDING_MODEL_FILENAME;
 
+/// Result of creating core services (store, node service, optional embeddings).
+///
+/// Extracted as a shared helper so both `init_services()` and
+/// `switch_database_services()` use identical tiered-init logic.
+pub(crate) struct ServiceBundle {
+    pub store: Arc<SurrealStore>,
+    pub node_service: Arc<NodeService>,
+    pub embedding_state: Option<EmbeddingState>,
+}
+
+/// Create core services for a given database + model path.
+///
+/// Tiered initialization:
+/// - Store and NodeService failures are fatal (returned as `Err`).
+/// - NLP/embedding failures are non-fatal: `embedding_state` is set to `None`
+///   and a warning is logged, allowing the app to run without semantic search.
+pub(crate) async fn create_service_bundle(
+    db_path: PathBuf,
+    model_path: PathBuf,
+) -> Result<ServiceBundle, String> {
+    // Initialize SurrealDB store
+    tracing::info!("Initializing SurrealDB store at {:?}...", db_path);
+    let mut store = Arc::new(SurrealStore::new(db_path).await.map_err(|e| {
+        let msg = format!("Failed to initialize database: {}", e);
+        eprintln!("❌ {}", msg);
+        msg
+    })?);
+    tracing::info!("SurrealDB store initialized");
+
+    // Initialize node service
+    tracing::info!("Initializing NodeService...");
+    let mut node_service = NodeService::new(&mut store)
+        .await
+        .map_err(|e| format!("Failed to initialize node service: {}", e))?;
+    tracing::info!("NodeService initialized");
+
+    // Tiered NLP init: failure here is non-fatal
+    tracing::info!("Initializing NLP engine (model: {:?})...", model_path);
+    let embedding_state = match create_embedding_state(&store, &mut node_service, &model_path) {
+        Ok(state) => {
+            tracing::info!("Embedding services initialized");
+            Some(state)
+        }
+        Err(e) => {
+            tracing::warn!(
+                "NLP/embedding init failed (semantic search disabled): {}",
+                e
+            );
+            None
+        }
+    };
+
+    let node_service_arc = Arc::new(node_service);
+
+    Ok(ServiceBundle {
+        store,
+        node_service: node_service_arc,
+        embedding_state,
+    })
+}
+
+/// Attempt to create embedding state (NLP engine + embedding service + processor).
+///
+/// Separated so the caller can treat the entire block as fallible without
+/// cluttering the happy path with nested matches.
+fn create_embedding_state(
+    store: &Arc<SurrealStore>,
+    node_service: &mut NodeService,
+    model_path: &Path,
+) -> Result<EmbeddingState, String> {
+    let embedding_config = EmbeddingConfig {
+        model_path: Some(model_path.to_path_buf()),
+        ..Default::default()
+    };
+
+    let mut nlp_engine = EmbeddingService::new(embedding_config)
+        .map_err(|e| format!("Failed to create NLP engine: {}", e))?;
+
+    nlp_engine
+        .initialize()
+        .map_err(|e| format!("Failed to load NLP model: {}", e))?;
+
+    let nlp_engine_arc = Arc::new(nlp_engine);
+    let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine_arc, store.clone()));
+
+    let processor = EmbeddingProcessor::new(embedding_service.clone())
+        .map_err(|e| format!("Failed to init embedding processor: {}", e))?;
+
+    node_service.set_embedding_waker(processor.waker());
+    processor.wake();
+    tracing::info!("EmbeddingProcessor waker connected and woken for stale embeddings");
+
+    Ok(EmbeddingState {
+        service: embedding_service,
+        processor: Arc::new(processor),
+    })
+}
+
 /// Resolve the path to the bundled NLP model (GGUF format for llama.cpp)
 ///
 /// Checks multiple locations in order:
@@ -56,12 +154,12 @@ fn resolve_bundled_model_path(app: &AppHandle) -> Result<PathBuf, String> {
 /// Reads database path, model path, and client ID from AppConfig.
 /// Populates AppServices with store, node_service, and embedding state.
 /// Starts background tasks (MCP server, domain event forwarder).
+///
+/// Uses tiered init: NLP failure is non-fatal (embedding_state = None).
 async fn init_services(app: &AppHandle, config: &crate::config::AppConfig) -> Result<(), String> {
     eprintln!("🔧 [init_services] Starting service initialization...");
-    tracing::info!("🔧 [init_services] Starting service initialization...");
+    tracing::info!("Starting service initialization...");
 
-    let db_path = config.database_path.clone();
-    let model_path = config.model_path.clone();
     let client_id = config.tauri_client_id.clone();
 
     // Check if already initialized via AppServices
@@ -73,110 +171,52 @@ async fn init_services(app: &AppHandle, config: &crate::config::AppConfig) -> Re
         );
     }
 
-    // Initialize SurrealDB store
-    eprintln!("🔧 [init_services] Initializing SurrealDB store...");
-    tracing::info!("🔧 [init_services] Initializing SurrealDB store...");
-    let mut store = Arc::new(SurrealStore::new(db_path).await.map_err(|e| {
-        let msg = format!("Failed to initialize database: {}", e);
-        eprintln!("❌ [init_services] {}", msg);
-        msg
-    })?);
-    eprintln!("✅ [init_services] SurrealDB store initialized");
-    tracing::info!("✅ [init_services] SurrealDB store initialized");
-
-    // Initialize node service with SurrealStore
-    tracing::info!("🔧 [init_services] Initializing NodeService...");
-    let mut node_service = NodeService::new(&mut store)
-        .await
-        .map_err(|e| format!("Failed to initialize node service: {}", e))?;
-    tracing::info!("✅ [init_services] NodeService initialized");
-
-    // Initialize NLP engine for embeddings
-    tracing::info!("🔧 [init_services] Initializing NLP engine...");
-    tracing::info!("🔧 [init_services] Using model path: {:?}", model_path);
-
-    let embedding_config = EmbeddingConfig {
-        model_path: Some(model_path),
-        ..Default::default()
-    };
-
-    let mut nlp_engine = EmbeddingService::new(embedding_config)
-        .map_err(|e| format!("Failed to initialize NLP engine: {}", e))?;
-
-    // Initialize the NLP engine (loads model)
-    nlp_engine
-        .initialize()
-        .map_err(|e| format!("Failed to load NLP model: {}", e))?;
-
-    let nlp_engine_arc = Arc::new(nlp_engine);
-    tracing::info!("✅ [init_services] NLP engine initialized");
-
-    // Initialize embedding service with SurrealStore
-    let embedding_service = NodeEmbeddingService::new(nlp_engine_arc.clone(), store.clone());
-    let embedding_service_arc = Arc::new(embedding_service);
-
-    // Initialize background embedding processor (event-driven, Issue #729)
-    let processor = EmbeddingProcessor::new(embedding_service_arc.clone())
-        .map_err(|e| format!("Failed to initialize embedding processor: {}", e))?;
-
-    // Wire up NodeService to wake processor on embedding changes (Issue #729)
-    node_service.set_embedding_waker(processor.waker());
-    tracing::info!("✅ [init_services] EmbeddingProcessor waker connected to NodeService");
-
-    // Wake processor on startup to process any existing stale embeddings
-    processor.wake();
-    tracing::info!("🔔 [init_services] EmbeddingProcessor woken to process stale embeddings");
-
-    let node_service_arc = Arc::new(node_service);
-    let processor_arc = Arc::new(processor);
+    // Create core services via shared helper (tiered NLP init)
+    let bundle =
+        create_service_bundle(config.database_path.clone(), config.model_path.clone()).await?;
 
     // Retrieve the shutdown token for background task coordination
     let shutdown_token: tauri::State<crate::ShutdownToken> = app.state();
     let session_token = shutdown_token.child_token();
 
+    // Extract embedding service Arc for MCP (before moving bundle.embedding_state)
+    let embedding_service_arc = bundle.embedding_state.as_ref().map(|es| es.service.clone());
+
     // Populate AppServices container (Issue #894)
-    eprintln!("🔧 [init_services] Populating AppServices container...");
-    tracing::info!("🔧 [init_services] Populating AppServices container...");
+    tracing::info!("Populating AppServices container...");
     services
         .initialize(
-            store.clone(),
-            node_service_arc.clone(),
-            Some(EmbeddingState {
-                service: embedding_service_arc.clone(),
-                processor: processor_arc.clone(),
-            }),
+            bundle.store.clone(),
+            bundle.node_service.clone(),
+            bundle.embedding_state,
             config.clone(),
             session_token.clone(),
         )
         .await;
-    eprintln!("✅ [init_services] AppServices container populated");
-    tracing::info!("✅ [init_services] AppServices container populated");
+    tracing::info!("AppServices container populated");
 
-    // Initialize MCP server now that NodeService is available
-    // Pass services directly instead of reading from Tauri state
+    // Initialize MCP server — starts even without embeddings (node CRUD still works)
     if let Err(e) = crate::initialize_mcp_server(
         app.clone(),
-        node_service_arc.clone(),
-        embedding_service_arc.clone(),
+        bundle.node_service.clone(),
+        embedding_service_arc,
         session_token.clone(),
     ) {
-        tracing::error!("❌ Failed to initialize MCP server: {}", e);
+        tracing::error!("Failed to initialize MCP server: {}", e);
         // Don't fail database init if MCP fails - MCP is optional
     }
 
     // Initialize domain event forwarding with client filtering (#665)
     if let Err(e) = crate::initialize_domain_event_forwarder(
         app.clone(),
-        node_service_arc.clone(),
+        bundle.node_service.clone(),
         client_id,
         session_token.clone(),
     ) {
-        tracing::error!("❌ Failed to initialize domain event forwarder: {}", e);
+        tracing::error!("Failed to initialize domain event forwarder: {}", e);
     }
 
-    let _ = store; // Store still available for direct access if needed
-
-    tracing::info!("✅ [init_services] Service initialization complete");
+    tracing::info!("Service initialization complete");
     Ok(())
 }
 
diff --git a/packages/desktop-app/src-tauri/src/commands/settings.rs b/packages/desktop-app/src-tauri/src/commands/settings.rs
index 9b0451eab..6a2f742cf 100644
--- a/packages/desktop-app/src-tauri/src/commands/settings.rs
+++ b/packages/desktop-app/src-tauri/src/commands/settings.rs
@@ -174,21 +174,17 @@ pub async fn reset_database_to_default(
 
 /// Hot-swap database services: create new store, node service, and embeddings,
 /// then atomically replace the running services and restart background tasks.
+///
+/// Uses the shared `create_service_bundle()` helper for consistent tiered init.
 async fn switch_database_services(
     app: &AppHandle,
     services: &AppServices,
     new_db_path: std::path::PathBuf,
 ) -> Result<(), String> {
-    use crate::app_services::EmbeddingState;
-    use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService};
-    use nodespace_core::{NodeService, SurrealStore};
-    use nodespace_nlp_engine::{EmbeddingConfig, EmbeddingService};
-    use std::sync::Arc;
-
     // Get current config for model path etc.
     let old_config = services.config().await.map_err(|e| e.message)?;
 
-    tracing::info!("🔧 Switching database to: {:?}", new_db_path);
+    tracing::info!("Switching database to: {:?}", new_db_path);
 
     // Ensure directory exists
     if let Some(parent) = new_db_path.parent() {
@@ -197,52 +193,10 @@ async fn switch_database_services(
             .map_err(|e| format!("Failed to create database directory: {}", e))?;
     }
 
-    // Create new store
-    let mut store = Arc::new(
-        SurrealStore::new(new_db_path.clone())
-            .await
-            .map_err(|e| format!("Failed to initialize new database: {}", e))?,
-    );
-
-    // Create new NodeService
-    let mut node_service = NodeService::new(&mut store)
-        .await
-        .map_err(|e| format!("Failed to initialize node service: {}", e))?;
-
-    // Create new embedding engine
-    let embedding_config = EmbeddingConfig {
-        model_path: Some(old_config.model_path.clone()),
-        ..Default::default()
-    };
-
-    let embedding_state = match EmbeddingService::new(embedding_config) {
-        Ok(mut nlp_engine) => match nlp_engine.initialize() {
-            Ok(()) => {
-                let nlp_arc = Arc::new(nlp_engine);
-                let emb_service = NodeEmbeddingService::new(nlp_arc.clone(), store.clone());
-                let emb_service_arc = Arc::new(emb_service);
-                let processor = EmbeddingProcessor::new(emb_service_arc.clone())
-                    .map_err(|e| format!("Failed to init embedding processor: {}", e))?;
-                node_service.set_embedding_waker(processor.waker());
-                processor.wake();
-                let processor_arc = Arc::new(processor);
-                Some(EmbeddingState {
-                    service: emb_service_arc,
-                    processor: processor_arc,
-                })
-            }
-            Err(e) => {
-                tracing::warn!("Failed to load NLP model during switch: {}", e);
-                None
-            }
-        },
-        Err(e) => {
-            tracing::warn!("Failed to create NLP engine during switch: {}", e);
-            None
-        }
-    };
-
-    let node_service_arc = Arc::new(node_service);
+    // Create core services via shared helper (tiered NLP init)
+    let bundle =
+        super::db::create_service_bundle(new_db_path.clone(), old_config.model_path.clone())
+            .await?;
 
     // Build new config
     let new_config = crate::config::AppConfig {
@@ -256,33 +210,33 @@ async fn switch_database_services(
     let shutdown_token: tauri::State<crate::ShutdownToken> = app.state();
     let new_session_token = shutdown_token.child_token();
 
+    // Extract embedding service Arc for MCP (before moving bundle.embedding_state)
+    let embedding_service_arc = bundle.embedding_state.as_ref().map(|es| es.service.clone());
+
     // Hot-swap services
-    let embedding_service_arc = embedding_state.as_ref().map(|es| es.service.clone());
     services
         .switch_database(
-            store.clone(),
-            node_service_arc.clone(),
-            embedding_state,
+            bundle.store.clone(),
+            bundle.node_service.clone(),
+            bundle.embedding_state,
             new_config.clone(),
             new_session_token.clone(),
         )
         .await;
 
-    // Restart background services with new session token
-    if let Some(emb_svc) = embedding_service_arc {
-        if let Err(e) = crate::initialize_mcp_server(
-            app.clone(),
-            node_service_arc.clone(),
-            emb_svc,
-            new_session_token.clone(),
-        ) {
-            tracing::error!("Failed to restart MCP server after switch: {}", e);
-        }
+    // Restart MCP server — starts even without embeddings (node CRUD still works)
+    if let Err(e) = crate::initialize_mcp_server(
+        app.clone(),
+        bundle.node_service.clone(),
+        embedding_service_arc,
+        new_session_token.clone(),
+    ) {
+        tracing::error!("Failed to restart MCP server after switch: {}", e);
     }
 
     if let Err(e) = crate::initialize_domain_event_forwarder(
         app.clone(),
-        node_service_arc.clone(),
+        bundle.node_service.clone(),
         new_config.tauri_client_id.clone(),
         new_session_token,
     ) {
@@ -292,6 +246,6 @@ async fn switch_database_services(
         );
     }
 
-    tracing::info!("✅ Database switch complete");
+    tracing::info!("Database switch complete");
     Ok(())
 }
diff --git a/packages/desktop-app/src-tauri/src/lib.rs b/packages/desktop-app/src-tauri/src/lib.rs
index 1761ffa56..aa5e72649 100644
--- a/packages/desktop-app/src-tauri/src/lib.rs
+++ b/packages/desktop-app/src-tauri/src/lib.rs
@@ -97,7 +97,7 @@ pub fn initialize_domain_event_forwarder(
 pub fn initialize_mcp_server(
     app: tauri::AppHandle,
     node_service: std::sync::Arc<nodespace_core::NodeService>,
-    embedding_service: std::sync::Arc<nodespace_core::services::NodeEmbeddingService>,
+    embedding_service: Option<std::sync::Arc<nodespace_core::services::NodeEmbeddingService>>,
     cancel_token: tokio_util::sync::CancellationToken,
 ) -> anyhow::Result<()> {
     use futures::FutureExt;
diff --git a/packages/desktop-app/src-tauri/src/mcp_integration.rs b/packages/desktop-app/src-tauri/src/mcp_integration.rs
index 9501a2936..5e8c1d52a 100644
--- a/packages/desktop-app/src-tauri/src/mcp_integration.rs
+++ b/packages/desktop-app/src-tauri/src/mcp_integration.rs
@@ -59,7 +59,7 @@ struct NodeDeletedEvent {
 /// * `McpResponseCallback` for starting the server with events
 pub fn create_mcp_service_with_events(
     node_service: Arc<NodeService>,
-    embedding_service: Arc<NodeEmbeddingService>,
+    embedding_service: Option<Arc<NodeEmbeddingService>>,
     app: AppHandle,
 ) -> (McpServerService, McpResponseCallback) {
     let port = default_mcp_port();
diff --git a/packages/dev-tools/src/bin/dev-mcp.rs b/packages/dev-tools/src/bin/dev-mcp.rs
index 5e195d9b2..134705de1 100644
--- a/packages/dev-tools/src/bin/dev-mcp.rs
+++ b/packages/dev-tools/src/bin/dev-mcp.rs
@@ -105,7 +105,7 @@ async fn main() -> anyhow::Result<()> {
 
     // Create MCP server service
     let port = default_mcp_port();
-    let mcp_service = McpServerService::new(node_service, embedding_service, port);
+    let mcp_service = McpServerService::new(node_service, Some(embedding_service), port);
 
     println!("\n🚀 Starting MCP server...");
     println!("   Port: {}", port);
diff --git a/packages/dev-tools/src/bin/dev-proxy.rs b/packages/dev-tools/src/bin/dev-proxy.rs
index 4804affc8..111f97ba0 100644
--- a/packages/dev-tools/src/bin/dev-proxy.rs
+++ b/packages/dev-tools/src/bin/dev-proxy.rs
@@ -460,7 +460,8 @@ async fn main() -> anyhow::Result<()> {
     // MCP runs on port 3100 by default (configurable via MCP_PORT env var)
     // No callback needed - MCP mutations emit DomainEvents via the shared NodeService
     let mcp_port = default_mcp_port();
-    let mcp_service = McpServerService::new(node_service.clone(), embedding_service, mcp_port);
+    let mcp_service =
+        McpServerService::new(node_service.clone(), Some(embedding_service), mcp_port);
 
     tokio::spawn(async move {
         println!("🔌 Starting MCP server on port {}...", mcp_port);
