diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b116d3..76c173af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Opening a database no longer writes to it: reads no longer rewrite the stored embedding settings or change the stored version, and the version is never downgraded. Embedding provider/name drift (matching `vector_dim`) warns on read-only opens and raises `ConfigMismatchError` on writable opens; reconcile with `rebuild --set-embedder`. +- Read CLI verbs (`list`, `get`, `search`, `visualize`, `ask`, `analyze`, `inspect`, `chat`, `info`, `history`) open the database read-only. ## [0.54.0] - 2026-06-04 diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 485c38d6..a9887ad1 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -276,7 +276,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: documents = await self.client.list_documents(filter=filter) @@ -328,7 +328,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: doc = await self.client.get_document_by_id(doc_id) @@ -385,7 +385,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: results = await self.client.search( @@ -407,7 +407,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: chunk = await self.client.get_chunk_by_id(chunk_id) @@ -451,7 +451,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: answer, citations = await self.client.ask(question, filter=filter) @@ -479,7 +479,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: self.console.print(f"[bold blue]Question:[/bold blue] {question}") @@ -566,6 +566,7 @@ class HaikuRAGApp: # pragma: no cover config=self.config, skip_validation=True, skip_migration_check=True, + read_only=self.read_only, ) as store: return await store.migrate() diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index d646baf2..9e7e21ab 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -406,7 +406,7 @@ def analyze( # pragma: no cover @_cli.command("settings", help="Display current configuration settings") def settings(): # pragma: no cover config = get_config() - app = HaikuRAGApp(db_path=Path(), config=config) + app = HaikuRAGApp(db_path=Path(), config=config, read_only=True) app.show_settings() @@ -612,7 +612,7 @@ def history( # pragma: no cover @_cli.command("download-models", help="Download Docling and Ollama models per config") def download_models_cmd(): # pragma: no cover - app = HaikuRAGApp(db_path=Path(), config=get_config()) + app = HaikuRAGApp(db_path=Path(), config=get_config(), read_only=True) try: asyncio.run(app.download_models()) except Exception as e: @@ -636,7 +636,7 @@ def inspect( # pragma: no cover raise typer.Exit(1) from e db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" - run_inspector(db_path, read_only=_read_only, before=_before) + run_inspector(db_path, read_only=True, before=_before) @_cli.command("chat", help="Launch interactive chat TUI for conversational RAG") @@ -666,7 +666,7 @@ def chat( # pragma: no cover run_chat( db_path, - read_only=_read_only, + read_only=True, before=_before, model=model, skills=skills, diff --git a/tests/store/test_read_only.py b/tests/store/test_read_only.py index 9a5e191c..df2fa2cc 100644 --- a/tests/store/test_read_only.py +++ b/tests/store/test_read_only.py @@ -298,3 +298,61 @@ class TestClientReadOnly: async with HaikuRAG(temp_db_path, read_only=True) as client: docs = await client.list_documents() assert len(docs) == 1 + + +class TestAppReadVerbsDoNotWrite: + @pytest.mark.asyncio + async def test_read_verb_leaves_settings_and_version_unchanged_on_drift( + self, temp_db_path + ): + """A read CLI verb opens read-only: drift warns but never writes.""" + from haiku.rag.app import HaikuRAGApp + from haiku.rag.config import AppConfig + + async with Store(temp_db_path, create=True) as store: + stored_name_before = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + version_before = await store.get_haiku_version() + + drift = AppConfig() + drift.embeddings.model.name = "different-model" + + # list is a read verb — must open read-only and not raise on drift + app = HaikuRAGApp(db_path=temp_db_path, config=drift) + await app.list_documents() + + async with Store(temp_db_path, skip_validation=True, read_only=True) as store: + stored_name_after = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + version_after = await store.get_haiku_version() + + assert stored_name_after == stored_name_before + assert version_after == version_before + + @pytest.mark.asyncio + async def test_write_verb_raises_on_drift_without_writing(self, temp_db_path): + """A write CLI verb opens writable: drift raises before any write.""" + from haiku.rag.app import HaikuRAGApp + from haiku.rag.config import AppConfig + from haiku.rag.store.repositories.settings import ConfigMismatchError + + async with Store(temp_db_path, create=True) as store: + stored_name_before = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + + drift = AppConfig() + drift.embeddings.model.name = "different-model" + + app = HaikuRAGApp(db_path=temp_db_path, config=drift) + with pytest.raises(ConfigMismatchError): + await app.add_document_from_text("hello") + + async with Store(temp_db_path, skip_validation=True, read_only=True) as store: + stored_name_after = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + + assert stored_name_after == stored_name_before