Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion clients/ts/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/vaara/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
oversight.
"""

__version__ = "0.19.0"
__version__ = "0.19.1"

from vaara.pipeline import InterceptionPipeline, InterceptionResult

Expand Down
70 changes: 56 additions & 14 deletions src/vaara/audit/sqlite_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions tests/test_sqlite_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down