diff --git a/CHANGELOG.md b/CHANGELOG.md index 159983d..7949293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.19.1] - 2026-05-18 + +**Patch: audit DB upgrade safety.** Opening an existing audit DB at +any schema version older than the current one crashed on first +MCP-server boot with `no such column: tenant_id`. The init path ran +`SCHEMA_SQL` before migrations, and `SCHEMA_SQL` contains indexes on +columns that later migrations add. + +Init now runs migrations from the stored version (or from v0 for +pre-versioned DBs that have no `audit_meta` row yet) BEFORE running +`SCHEMA_SQL` idempotently. Fresh DBs continue to use the existing +single-pass `SCHEMA_SQL` path. + +Tests added for the v0 (pre-versioning), v1, and current-version +open paths. + ## [0.19.0] - 2026-05-17 **Theme: Big Cloud guardrail adapters.** Adds three adapters that take diff --git a/clients/ts/package.json b/clients/ts/package.json index 2ed2a4c..c2b2287 100644 --- a/clients/ts/package.json +++ b/clients/ts/package.json @@ -1,6 +1,6 @@ { "name": "@vaara/client", - "version": "0.19.0", + "version": "0.19.1", "description": "TypeScript client for the Vaara HTTP API — conformal risk scoring, hash-chained audit, policy reload, named detectors.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/pyproject.toml b/pyproject.toml index 58150e6..0aef0aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.19.0" +version = "0.19.1" description = "Adaptive AI Agent Execution Layer for risk scoring, audit trails, and regulatory compliance" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/src/vaara/__init__.py b/src/vaara/__init__.py index 583d894..3d01478 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.19.0" +__version__ = "0.19.1" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/audit/sqlite_backend.py b/src/vaara/audit/sqlite_backend.py index d369a4c..13ec5fe 100644 --- a/src/vaara/audit/sqlite_backend.py +++ b/src/vaara/audit/sqlite_backend.py @@ -204,27 +204,69 @@ def __init__(self, db_path: str | Path, tenant_id: str = "") -> None: # Load GDPR redaction map into memory for O(1) read-time substitution. self._redaction_cache: dict[str, str] = self._load_redaction_cache() - def _init_schema(self) -> None: - """Create tables if they don't exist, then run any pending migrations.""" - self._conn.executescript(SCHEMA_SQL) + def _table_exists(self, name: str) -> bool: + row = self._conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + return row is not None + + def _stored_schema_version(self) -> int | None: + if not self._table_exists("audit_meta"): + return None row = self._conn.execute( "SELECT value FROM audit_meta WHERE key='schema_version'" ).fetchone() - if row is None: - # Fresh database — already at current version via SCHEMA_SQL. + return int(row[0]) if row is not None else None + + def _init_schema(self) -> None: + """Create tables if they don't exist, then run any pending migrations. + + Migrations run BEFORE SCHEMA_SQL on any existing DB, because + SCHEMA_SQL contains statements (indexes on later-added columns) + that fail on a DB at any older version. Cases: + + 1. Fresh DB: no audit_records table yet. SCHEMA_SQL creates + everything at SCHEMA_VERSION. + 2. Existing DB at v < SCHEMA_VERSION: migrate from stored version + up to SCHEMA_VERSION, then run SCHEMA_SQL idempotently to pick + up any tables/indexes that aren't carried by migrations. + 3. Existing DB at v == SCHEMA_VERSION: no migrations, SCHEMA_SQL + is a no-op via IF NOT EXISTS. + 4. Existing DB pre-versioning (no audit_meta or no schema_version + row): treated as v0 and migrated forward. + """ + if not self._table_exists("audit_records"): + # Fresh DB + self._conn.executescript(SCHEMA_SQL) self._conn.execute( "INSERT INTO audit_meta (key, value) VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),), ) - else: - stored = int(row[0]) - if stored > SCHEMA_VERSION: - raise RuntimeError( - f"Audit DB schema version {stored} is newer than this vaara " - f"version (supports up to {SCHEMA_VERSION}). Upgrade vaara." - ) - if stored < SCHEMA_VERSION: - self._run_migrations(stored, SCHEMA_VERSION) + return + # Existing DB: ensure audit_meta exists, resolve stored version + if not self._table_exists("audit_meta"): + self._conn.execute( + "CREATE TABLE audit_meta " + "(key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + stored = self._stored_schema_version() + if stored is None: + # Pre-versioned DB: treat as v0 and bring forward via migrations + self._conn.execute( + "INSERT INTO audit_meta (key, value) VALUES ('schema_version', '0')" + ) + stored = 0 + if stored > SCHEMA_VERSION: + raise RuntimeError( + f"Audit DB schema version {stored} is newer than this vaara " + f"version (supports up to {SCHEMA_VERSION}). Upgrade vaara." + ) + if stored < SCHEMA_VERSION: + self._run_migrations(stored, SCHEMA_VERSION) + # SCHEMA_SQL is now safe to run idempotently. All referenced + # columns exist because migrations have brought the DB current. + self._conn.executescript(SCHEMA_SQL) def _run_migrations(self, from_version: int, to_version: int) -> None: """Apply incremental schema migrations from_version up to to_version. diff --git a/tests/test_sqlite_backend.py b/tests/test_sqlite_backend.py index ee82609..48bb498 100644 --- a/tests/test_sqlite_backend.py +++ b/tests/test_sqlite_backend.py @@ -178,6 +178,80 @@ def test_persistence_across_connections(self, db_path, sample_action_type): assert backend.count() == 8 +class TestSchemaUpgrade: + """Opening a DB at any older schema version must migrate cleanly. + + Regression for the v0.19.0 init bug where SCHEMA_SQL ran before + migrations and crashed with `no such column: tenant_id` on any DB + that had not yet been brought to the tenant_id-bearing version. + """ + + _V0_AUDIT_RECORDS_SQL = """ + CREATE TABLE audit_records ( + record_id TEXT PRIMARY KEY, + action_id TEXT NOT NULL, + event_type TEXT NOT NULL, + timestamp REAL NOT NULL, + agent_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', + regulatory TEXT NOT NULL DEFAULT '[]', + previous_hash TEXT NOT NULL DEFAULT '', + record_hash TEXT NOT NULL DEFAULT '', + seq INTEGER NOT NULL + ); + """ + + def _seed_v0_db(self, path: Path) -> None: + """Build a pre-versioning DB by hand: no audit_meta, no tenant_id.""" + import sqlite3 + conn = sqlite3.connect(str(path), isolation_level=None) + conn.executescript(self._V0_AUDIT_RECORDS_SQL) + conn.close() + + def _seed_v1_db(self, path: Path) -> None: + """Build a v1 DB: audit_meta exists with schema_version='1', + audit_records has no tenant_id yet.""" + import sqlite3 + conn = sqlite3.connect(str(path), isolation_level=None) + conn.executescript(self._V0_AUDIT_RECORDS_SQL) + conn.execute("CREATE TABLE audit_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + conn.execute("INSERT INTO audit_meta (key, value) VALUES ('schema_version', '1')") + conn.close() + + def _assert_current(self, path: Path) -> None: + import sqlite3 + from vaara.audit.sqlite_backend import SCHEMA_VERSION + conn = sqlite3.connect(str(path)) + v = conn.execute( + "SELECT value FROM audit_meta WHERE key='schema_version'" + ).fetchone() + assert v is not None + assert int(v[0]) == SCHEMA_VERSION + cols = [r[1] for r in conn.execute("PRAGMA table_info(audit_records)").fetchall()] + assert "tenant_id" in cols + assert "system_operation" in cols + assert "data_usage" in cols + assert "decision_making" in cols + assert "limitations" in cols + conn.close() + + def test_preversion_db_migrates(self, db_path): + self._seed_v0_db(db_path) + SQLiteAuditBackend(db_path).close() + self._assert_current(db_path) + + def test_v1_db_migrates(self, db_path): + self._seed_v1_db(db_path) + SQLiteAuditBackend(db_path).close() + self._assert_current(db_path) + + def test_reopening_current_db_is_idempotent(self, db_path): + SQLiteAuditBackend(db_path).close() + SQLiteAuditBackend(db_path).close() + self._assert_current(db_path) + + class TestSkeletonRecordsCounter: """Loop 51: load_trail reports skeleton rows via log only; the count is lost to callers. Ops dashboards polling trail.persistence_failures