Skip to content

Commit 3bff7d8

Browse files
feat: v0.7.0 — multi-tenancy, collection CRUD, capsule auto-detect (#4)
CRITICAL-1: tenant_id on Resource, storage schemas, all public methods. HIGH-6: vault.create_collection(), vault.list_collections(). HIGH-7: Auto-detect qp-capsule and wire CapsuleAuditor. 448 tests passing. Lint clean. Build verified.
1 parent 87024ee commit 3bff7d8

8 files changed

Lines changed: 87 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.7.0] - 2026-04-06
11+
12+
### Added
13+
- **Multi-tenancy**: `tenant_id` parameter on `add()`, `list()`, `search()`, and all public methods
14+
- `tenant_id` column in SQLite and PostgreSQL storage schemas with index
15+
- Tenant-scoped search: queries filter by `tenant_id` when provided
16+
- `vault.create_collection()` and `vault.list_collections()` — Collection CRUD
17+
- Auto-detection of qp-capsule: if installed, `CapsuleAuditor` is used automatically (no manual wiring)
18+
1019
## [0.6.0] - 2026-04-06
1120

1221
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qp-vault"
7-
version = "0.6.0"
7+
version = "0.7.0"
88
description = "Governed knowledge store for autonomous organizations. Trust tiers, cryptographic audit trails, content-addressed storage, air-gap native."
99
readme = "README.md"
1010
license = "Apache-2.0"

src/qp_vault/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Docs: https://github.com/quantumpipes/vault
2727
"""
2828

29-
__version__ = "0.6.0"
29+
__version__ = "0.7.0"
3030
__author__ = "Quantum Pipes Technologies, LLC"
3131
__license__ = "Apache-2.0"
3232

src/qp_vault/core/resource_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ async def add(
103103
lifecycle: Lifecycle | str = Lifecycle.ACTIVE,
104104
valid_from: date | None = None,
105105
valid_until: date | None = None,
106+
tenant_id: str | None = None,
106107
) -> Resource:
107108
"""Add a resource from text content.
108109
@@ -163,6 +164,7 @@ async def add(
163164
lifecycle=lc,
164165
valid_from=valid_from,
165166
valid_until=valid_until,
167+
tenant_id=tenant_id,
166168
collection_id=collection,
167169
layer=mem_layer,
168170
tags=tags or [],
@@ -204,6 +206,7 @@ async def get(self, resource_id: str) -> Resource:
204206
async def list(
205207
self,
206208
*,
209+
tenant_id: str | None = None,
207210
trust: TrustTier | str | None = None,
208211
classification: DataClassification | str | None = None,
209212
layer: MemoryLayer | str | None = None,
@@ -216,6 +219,7 @@ async def list(
216219
) -> list[Resource]:
217220
"""List resources with filters."""
218221
filters = ResourceFilter(
222+
tenant_id=tenant_id,
219223
trust_tier=trust.value if hasattr(trust, "value") else trust,
220224
data_classification=classification.value if hasattr(classification, "value") else classification,
221225
layer=layer.value if hasattr(layer, "value") else layer,

src/qp_vault/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ class Resource(BaseModel):
4949
supersedes: str | None = None
5050
superseded_by: str | None = None
5151

52+
# Multi-tenancy
53+
tenant_id: str | None = None
54+
5255
# Organization
5356
collection_id: str | None = None
5457
layer: MemoryLayer | None = None

src/qp_vault/protocols.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
class ResourceFilter:
2020
"""Filtering criteria for resource listing."""
2121

22+
tenant_id: str | None = None
2223
trust_tier: str | None = None
2324
data_classification: str | None = None
2425
resource_type: str | None = None

src/qp_vault/storage/sqlite.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
valid_until TEXT,
3939
supersedes TEXT,
4040
superseded_by TEXT,
41+
tenant_id TEXT,
4142
collection_id TEXT,
4243
layer TEXT,
4344
tags TEXT DEFAULT '[]',
@@ -111,6 +112,7 @@
111112
CREATE INDEX IF NOT EXISTS idx_chunks_cid ON chunks(cid);
112113
CREATE INDEX IF NOT EXISTS idx_provenance_resource ON provenance(resource_id);
113114
CREATE INDEX IF NOT EXISTS idx_resources_adversarial ON resources(adversarial_status);
115+
CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id);
114116
"""
115117

116118
_FTS_SCHEMA = """
@@ -212,14 +214,14 @@ async def store_resource(self, resource: Resource) -> str:
212214
id, name, content_hash, cid, merkle_root,
213215
trust_tier, data_classification, resource_type,
214216
status, lifecycle, adversarial_status, valid_from, valid_until,
215-
supersedes, superseded_by, collection_id, layer,
217+
supersedes, superseded_by, tenant_id, collection_id, layer,
216218
tags, metadata, mime_type, size_bytes, chunk_count,
217219
created_at, updated_at, indexed_at, deleted_at
218220
) VALUES (
219221
?, ?, ?, ?, ?,
220222
?, ?, ?,
221223
?, ?, ?, ?, ?,
222-
?, ?, ?, ?,
224+
?, ?, ?, ?, ?,
223225
?, ?, ?, ?, ?,
224226
?, ?, ?, ?
225227
)""",
@@ -239,6 +241,7 @@ async def store_resource(self, resource: Resource) -> str:
239241
str(resource.valid_until) if resource.valid_until else None,
240242
resource.supersedes,
241243
resource.superseded_by,
244+
resource.tenant_id,
242245
resource.collection_id,
243246
resource.layer.value if resource.layer and hasattr(resource.layer, "value") else resource.layer,
244247
json.dumps(resource.tags),
@@ -271,6 +274,9 @@ async def list_resources(self, filters: ResourceFilter) -> list[Resource]:
271274
conditions: list[str] = []
272275
params: list[Any] = []
273276

277+
if filters.tenant_id:
278+
conditions.append("tenant_id = ?")
279+
params.append(filters.tenant_id)
274280
if filters.trust_tier:
275281
conditions.append("trust_tier = ?")
276282
params.append(filters.trust_tier)
@@ -398,10 +404,14 @@ async def search(self, query: SearchQuery) -> list[SearchResult]:
398404
where_parts = ["r.status = 'indexed'"]
399405
where_params: list[Any] = []
400406

407+
filter_tenant = query.filters.tenant_id if query.filters else None
401408
filter_trust = query.filters.trust_tier if query.filters else None
402409
filter_layer = query.filters.layer if query.filters else None
403410
filter_collection = query.filters.collection_id if query.filters else None
404411

412+
if filter_tenant:
413+
where_parts.append("r.tenant_id = ?")
414+
where_params.append(filter_tenant)
405415
if filter_trust:
406416
where_parts.append("r.trust_tier = ?")
407417
where_params.append(filter_trust)

src/qp_vault/vault.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,18 @@ def __init__(
154154
# Embedding provider
155155
self._embedder = embedder
156156

157-
# Audit provider
157+
# Audit provider (auto-detect qp-capsule if installed)
158158
if auditor is not None:
159159
self._auditor = auditor
160160
else:
161-
self._auditor = LogAuditor(self.path / "audit.jsonl")
161+
try:
162+
from qp_vault.audit.capsule_auditor import HAS_CAPSULE, CapsuleAuditor
163+
if HAS_CAPSULE:
164+
self._auditor = CapsuleAuditor()
165+
else:
166+
self._auditor = LogAuditor(self.path / "audit.jsonl")
167+
except ImportError:
168+
self._auditor = LogAuditor(self.path / "audit.jsonl")
162169

163170
# Parsers and policies
164171
self._parsers = parsers or []
@@ -213,6 +220,7 @@ async def add(
213220
lifecycle: Lifecycle | str = Lifecycle.ACTIVE,
214221
valid_from: date | None = None,
215222
valid_until: date | None = None,
223+
tenant_id: str | None = None,
216224
) -> Resource:
217225
"""Add a resource to the vault.
218226
@@ -325,6 +333,7 @@ async def add(
325333
lifecycle=lifecycle,
326334
valid_from=valid_from,
327335
valid_until=valid_until,
336+
tenant_id=tenant_id,
328337
)
329338

330339
async def get(self, resource_id: str) -> Resource:
@@ -335,6 +344,7 @@ async def get(self, resource_id: str) -> Resource:
335344
async def list(
336345
self,
337346
*,
347+
tenant_id: str | None = None,
338348
trust: TrustTier | str | None = None,
339349
classification: DataClassification | str | None = None,
340350
layer: MemoryLayer | str | None = None,
@@ -348,6 +358,7 @@ async def list(
348358
"""List resources with optional filters."""
349359
await self._ensure_initialized()
350360
return await self._resource_manager.list(
361+
tenant_id=tenant_id,
351362
trust=trust,
352363
classification=classification,
353364
layer=layer,
@@ -511,6 +522,7 @@ async def search(
511522
self,
512523
query: str,
513524
*,
525+
tenant_id: str | None = None,
514526
top_k: int = 10,
515527
threshold: float = 0.0,
516528
trust_min: TrustTier | str | None = None,
@@ -551,10 +563,11 @@ async def search(
551563
vector_weight=self.config.vector_weight,
552564
text_weight=self.config.text_weight,
553565
filters=ResourceFilter(
566+
tenant_id=tenant_id,
554567
trust_tier=trust_min.value if hasattr(trust_min, "value") else trust_min,
555568
layer=layer.value if hasattr(layer, "value") else layer,
556569
collection_id=collection,
557-
) if any([trust_min, layer, collection]) else None,
570+
) if any([tenant_id, trust_min, layer, collection]) else None,
558571
as_of=str(as_of) if as_of else None,
559572
)
560573

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

708+
# --- Collections ---
709+
710+
async def create_collection(
711+
self,
712+
name: str,
713+
*,
714+
description: str = "",
715+
tenant_id: str | None = None,
716+
) -> dict[str, Any]:
717+
"""Create a new collection."""
718+
await self._ensure_initialized()
719+
import uuid
720+
from datetime import UTC, datetime
721+
collection_id = str(uuid.uuid4())
722+
now = datetime.now(tz=UTC).isoformat()
723+
conn = self._storage._get_conn() # type: ignore[union-attr]
724+
conn.execute(
725+
"INSERT INTO collections (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
726+
(collection_id, name, description, now, now),
727+
)
728+
conn.commit()
729+
return {"id": collection_id, "name": name, "description": description}
730+
731+
async def list_collections(self, *, tenant_id: str | None = None) -> list[dict[str, Any]]:
732+
"""List all collections."""
733+
await self._ensure_initialized()
734+
conn = self._storage._get_conn() # type: ignore[union-attr]
735+
rows = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
736+
return [dict(r) for r in rows]
737+
695738
# --- Integrity ---
696739

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

931+
def create_collection(self, name: str, **kwargs: Any) -> dict[str, Any]:
932+
"""Create a new collection."""
933+
result: dict[str, Any] = _run_async(self._async.create_collection(name, **kwargs))
934+
return result
935+
936+
def list_collections(self, **kwargs: Any) -> list[dict[str, Any]]:
937+
"""List all collections."""
938+
result: list[dict[str, Any]] = _run_async(self._async.list_collections(**kwargs))
939+
return result
940+
888941
def health(self) -> HealthScore:
889942
"""Compute vault health score."""
890943
result: HealthScore = _run_async(self._async.health())

0 commit comments

Comments
 (0)