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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0] - 2026-04-06

### Added
- **Multi-tenancy**: `tenant_id` parameter on `add()`, `list()`, `search()`, and all public methods
- `tenant_id` column in SQLite and PostgreSQL storage schemas with index
- Tenant-scoped search: queries filter by `tenant_id` when provided
- `vault.create_collection()` and `vault.list_collections()` — Collection CRUD
- Auto-detection of qp-capsule: if installed, `CapsuleAuditor` is used automatically (no manual wiring)

## [0.6.0] - 2026-04-06

### Added
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 = "hatchling.build"

[project]
name = "qp-vault"
version = "0.6.0"
version = "0.7.0"
description = "Governed knowledge store for autonomous organizations. Trust tiers, cryptographic audit trails, content-addressed storage, air-gap native."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/qp_vault/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
Docs: https://github.com/quantumpipes/vault
"""

__version__ = "0.6.0"
__version__ = "0.7.0"
__author__ = "Quantum Pipes Technologies, LLC"
__license__ = "Apache-2.0"

Expand Down
4 changes: 4 additions & 0 deletions src/qp_vault/core/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ async def add(
lifecycle: Lifecycle | str = Lifecycle.ACTIVE,
valid_from: date | None = None,
valid_until: date | None = None,
tenant_id: str | None = None,
) -> Resource:
"""Add a resource from text content.

Expand Down Expand Up @@ -163,6 +164,7 @@ async def add(
lifecycle=lc,
valid_from=valid_from,
valid_until=valid_until,
tenant_id=tenant_id,
collection_id=collection,
layer=mem_layer,
tags=tags or [],
Expand Down Expand Up @@ -204,6 +206,7 @@ async def get(self, resource_id: str) -> Resource:
async def list(
self,
*,
tenant_id: str | None = None,
trust: TrustTier | str | None = None,
classification: DataClassification | str | None = None,
layer: MemoryLayer | str | None = None,
Expand All @@ -216,6 +219,7 @@ async def list(
) -> list[Resource]:
"""List resources with filters."""
filters = ResourceFilter(
tenant_id=tenant_id,
trust_tier=trust.value if hasattr(trust, "value") else trust,
data_classification=classification.value if hasattr(classification, "value") else classification,
layer=layer.value if hasattr(layer, "value") else layer,
Expand Down
3 changes: 3 additions & 0 deletions src/qp_vault/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ class Resource(BaseModel):
supersedes: str | None = None
superseded_by: str | None = None

# Multi-tenancy
tenant_id: str | None = None

# Organization
collection_id: str | None = None
layer: MemoryLayer | None = None
Expand Down
1 change: 1 addition & 0 deletions src/qp_vault/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
class ResourceFilter:
"""Filtering criteria for resource listing."""

tenant_id: str | None = None
trust_tier: str | None = None
data_classification: str | None = None
resource_type: str | None = None
Expand Down
14 changes: 12 additions & 2 deletions src/qp_vault/storage/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
valid_until TEXT,
supersedes TEXT,
superseded_by TEXT,
tenant_id TEXT,
collection_id TEXT,
layer TEXT,
tags TEXT DEFAULT '[]',
Expand Down Expand Up @@ -111,6 +112,7 @@
CREATE INDEX IF NOT EXISTS idx_chunks_cid ON chunks(cid);
CREATE INDEX IF NOT EXISTS idx_provenance_resource ON provenance(resource_id);
CREATE INDEX IF NOT EXISTS idx_resources_adversarial ON resources(adversarial_status);
CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id);
"""

_FTS_SCHEMA = """
Expand Down Expand Up @@ -212,14 +214,14 @@ async def store_resource(self, resource: Resource) -> str:
id, name, content_hash, cid, merkle_root,
trust_tier, data_classification, resource_type,
status, lifecycle, adversarial_status, valid_from, valid_until,
supersedes, superseded_by, collection_id, layer,
supersedes, superseded_by, tenant_id, collection_id, layer,
tags, metadata, mime_type, size_bytes, chunk_count,
created_at, updated_at, indexed_at, deleted_at
) VALUES (
?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?
)""",
Expand All @@ -239,6 +241,7 @@ async def store_resource(self, resource: Resource) -> str:
str(resource.valid_until) if resource.valid_until else None,
resource.supersedes,
resource.superseded_by,
resource.tenant_id,
resource.collection_id,
resource.layer.value if resource.layer and hasattr(resource.layer, "value") else resource.layer,
json.dumps(resource.tags),
Expand Down Expand Up @@ -271,6 +274,9 @@ async def list_resources(self, filters: ResourceFilter) -> list[Resource]:
conditions: list[str] = []
params: list[Any] = []

if filters.tenant_id:
conditions.append("tenant_id = ?")
params.append(filters.tenant_id)
if filters.trust_tier:
conditions.append("trust_tier = ?")
params.append(filters.trust_tier)
Expand Down Expand Up @@ -398,10 +404,14 @@ async def search(self, query: SearchQuery) -> list[SearchResult]:
where_parts = ["r.status = 'indexed'"]
where_params: list[Any] = []

filter_tenant = query.filters.tenant_id if query.filters else None
filter_trust = query.filters.trust_tier if query.filters else None
filter_layer = query.filters.layer if query.filters else None
filter_collection = query.filters.collection_id if query.filters else None

if filter_tenant:
where_parts.append("r.tenant_id = ?")
where_params.append(filter_tenant)
if filter_trust:
where_parts.append("r.trust_tier = ?")
where_params.append(filter_trust)
Expand Down
59 changes: 56 additions & 3 deletions src/qp_vault/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,18 @@ def __init__(
# Embedding provider
self._embedder = embedder

# Audit provider
# Audit provider (auto-detect qp-capsule if installed)
if auditor is not None:
self._auditor = auditor
else:
self._auditor = LogAuditor(self.path / "audit.jsonl")
try:
from qp_vault.audit.capsule_auditor import HAS_CAPSULE, CapsuleAuditor
if HAS_CAPSULE:
self._auditor = CapsuleAuditor()
else:
self._auditor = LogAuditor(self.path / "audit.jsonl")
except ImportError:
self._auditor = LogAuditor(self.path / "audit.jsonl")

# Parsers and policies
self._parsers = parsers or []
Expand Down Expand Up @@ -213,6 +220,7 @@ async def add(
lifecycle: Lifecycle | str = Lifecycle.ACTIVE,
valid_from: date | None = None,
valid_until: date | None = None,
tenant_id: str | None = None,
) -> Resource:
"""Add a resource to the vault.

Expand Down Expand Up @@ -325,6 +333,7 @@ async def add(
lifecycle=lifecycle,
valid_from=valid_from,
valid_until=valid_until,
tenant_id=tenant_id,
)

async def get(self, resource_id: str) -> Resource:
Expand All @@ -335,6 +344,7 @@ async def get(self, resource_id: str) -> Resource:
async def list(
self,
*,
tenant_id: str | None = None,
trust: TrustTier | str | None = None,
classification: DataClassification | str | None = None,
layer: MemoryLayer | str | None = None,
Expand All @@ -348,6 +358,7 @@ async def list(
"""List resources with optional filters."""
await self._ensure_initialized()
return await self._resource_manager.list(
tenant_id=tenant_id,
trust=trust,
classification=classification,
layer=layer,
Expand Down Expand Up @@ -511,6 +522,7 @@ async def search(
self,
query: str,
*,
tenant_id: str | None = None,
top_k: int = 10,
threshold: float = 0.0,
trust_min: TrustTier | str | None = None,
Expand Down Expand Up @@ -551,10 +563,11 @@ async def search(
vector_weight=self.config.vector_weight,
text_weight=self.config.text_weight,
filters=ResourceFilter(
tenant_id=tenant_id,
trust_tier=trust_min.value if hasattr(trust_min, "value") else trust_min,
layer=layer.value if hasattr(layer, "value") else layer,
collection_id=collection,
) if any([trust_min, layer, collection]) else None,
) if any([tenant_id, trust_min, layer, collection]) else None,
as_of=str(as_of) if as_of else None,
)

Expand Down Expand Up @@ -692,6 +705,36 @@ def layer(self, name: MemoryLayer | str) -> LayerView:
layer_enum = MemoryLayer(name) if isinstance(name, str) else name
return LayerView(layer_enum, self, self._layer_manager)

# --- Collections ---

async def create_collection(
self,
name: str,
*,
description: str = "",
tenant_id: str | None = None,
) -> dict[str, Any]:
"""Create a new collection."""
await self._ensure_initialized()
import uuid
from datetime import UTC, datetime
collection_id = str(uuid.uuid4())
now = datetime.now(tz=UTC).isoformat()
conn = self._storage._get_conn() # type: ignore[union-attr]
conn.execute(
"INSERT INTO collections (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(collection_id, name, description, now, now),
)
conn.commit()
return {"id": collection_id, "name": name, "description": description}

async def list_collections(self, *, tenant_id: str | None = None) -> list[dict[str, Any]]:
"""List all collections."""
await self._ensure_initialized()
conn = self._storage._get_conn() # type: ignore[union-attr]
rows = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
return [dict(r) for r in rows]

# --- Integrity ---

async def health(self) -> HealthScore:
Expand Down Expand Up @@ -885,6 +928,16 @@ def layer(self, name: MemoryLayer | str) -> LayerView: # type: ignore[return-va
"""Get a scoped view of a memory layer."""
return self._async.layer(name) # type: ignore[return-value]

def create_collection(self, name: str, **kwargs: Any) -> dict[str, Any]:
"""Create a new collection."""
result: dict[str, Any] = _run_async(self._async.create_collection(name, **kwargs))
return result

def list_collections(self, **kwargs: Any) -> list[dict[str, Any]]:
"""List all collections."""
result: list[dict[str, Any]] = _run_async(self._async.list_collections(**kwargs))
return result

def health(self) -> HealthScore:
"""Compute vault health score."""
result: HealthScore = _run_async(self._async.health())
Expand Down
Loading