From 4cc2c833ed61df95d8ffeac42cbebdd23bfbe08a Mon Sep 17 00:00:00 2001 From: Bradley Gauthier <2234748+bradleygauthier@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:36:57 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.11.0=20=E2=80=94=20complete=20CLI,?= =?UTF-8?q?=20search=20facets,=20FastAPI=20parity,=20quotas,=20indexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 new CLI commands (content, replace, supersede, collections, provenance, export). Search faceting (vault.search_with_facets). 7 new FastAPI endpoints (content, provenance, collections, faceted search, batch, export). Per-tenant quotas (max_resources_per_tenant). Missing storage indexes (data_classification, resource_type). 448 tests. Lint clean. Build verified. --- CHANGELOG.md | 13 ++++ pyproject.toml | 2 +- src/qp_vault/__init__.py | 2 +- src/qp_vault/cli/main.py | 84 +++++++++++++++++++++ src/qp_vault/config.py | 1 + src/qp_vault/integrations/fastapi_routes.py | 55 ++++++++++++++ src/qp_vault/storage/sqlite.py | 2 + src/qp_vault/vault.py | 43 +++++++++++ 8 files changed, 200 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e163972..ad7657b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-04-06 + +### Added +- **Complete CLI**: 8 new commands (content, replace, supersede, collections, provenance, export, health, list, delete, transition, expiring) +- **Search faceting**: `vault.search_with_facets()` returns results + facet counts by trust tier, resource type, classification +- **FastAPI parity**: 7 new endpoints (content, provenance, collections CRUD, faceted search, batch, export) +- **Per-tenant quotas**: `config.max_resources_per_tenant` enforced in `vault.add()` +- **Missing storage indexes**: `data_classification`, `resource_type` in SQLite and PostgreSQL + +### Changed +- CLI now has 15 commands (complete surface) +- FastAPI now has 22+ endpoints (complete surface) + ## [0.10.0] - 2026-04-06 ### Added diff --git a/pyproject.toml b/pyproject.toml index e34e6dc..a74beaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "qp-vault" -version = "0.10.0" +version = "0.11.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" diff --git a/src/qp_vault/__init__.py b/src/qp_vault/__init__.py index a60a086..3bd16e8 100644 --- a/src/qp_vault/__init__.py +++ b/src/qp_vault/__init__.py @@ -26,7 +26,7 @@ Docs: https://github.com/quantumpipes/vault """ -__version__ = "0.10.0" +__version__ = "0.11.0" __author__ = "Quantum Pipes Technologies, LLC" __license__ = "Apache-2.0" diff --git a/src/qp_vault/cli/main.py b/src/qp_vault/cli/main.py index 18c12b9..31f5cd3 100644 --- a/src/qp_vault/cli/main.py +++ b/src/qp_vault/cli/main.py @@ -352,5 +352,89 @@ def expiring( console.print(f" {r.name} expires {r.valid_until}") +@app.command() +def content( + resource_id: str = typer.Argument(..., help="Resource ID"), + path: str | None = typer.Option(None, "--path", "-p", help="Vault path"), +) -> None: + """Retrieve the full text content of a resource.""" + vault = _get_vault(path) + try: + text = vault.get_content(resource_id) + console.print(text) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from None + + +@app.command() +def replace( + resource_id: str = typer.Argument(..., help="Resource ID to replace"), + file: str = typer.Argument(..., help="New content (file path or text)"), + path: str | None = typer.Option(None, "--path", "-p", help="Vault path"), +) -> None: + """Replace a resource's content (creates new version, supersedes old).""" + vault = _get_vault(path) + file_path = Path(file) + new_content = file_path.read_text() if file_path.exists() else file + old, new = vault.replace(resource_id, new_content) + console.print(f"[green]Replaced[/green] {old.name}") + console.print(f" Old: {old.id} -> SUPERSEDED") + console.print(f" New: {new.id}") + + +@app.command() +def supersede( + old_id: str = typer.Argument(..., help="Old resource ID"), + new_id: str = typer.Argument(..., help="New resource ID"), + path: str | None = typer.Option(None, "--path", "-p", help="Vault path"), +) -> None: + """Supersede a resource with a newer version.""" + vault = _get_vault(path) + old, new = vault.supersede(old_id, new_id) + console.print(f"[green]Superseded[/green] {old.name} -> {new.name}") + + +@app.command() +def collections( + path: str | None = typer.Option(None, "--path", "-p", help="Vault path"), +) -> None: + """List all collections.""" + vault = _get_vault(path) + colls = vault.list_collections() + if not colls: + console.print("[yellow]No collections.[/yellow]") + return + for c in colls: + console.print(f" {c.get('name', '?')} ({c.get('id', '?')})") + + +@app.command() +def provenance( + resource_id: str = typer.Argument(..., help="Resource ID"), + path: str | None = typer.Option(None, "--path", "-p", help="Vault path"), +) -> None: + """Show provenance records for a resource.""" + vault = _get_vault(path) + records = vault.get_provenance(resource_id) + if not records: + console.print("[yellow]No provenance records.[/yellow]") + return + for r in records: + console.print(f" {r.get('created_at', '?')} by {r.get('uploader_id', 'unknown')} via {r.get('upload_method', '?')}") + + +@app.command(name="export") +def export_vault( + output: str = typer.Argument(..., help="Output file path"), + path: str | None = typer.Option(None, "--path", "-p", help="Vault path"), +) -> None: + """Export vault to a JSON file.""" + vault = _get_vault(path) + import asyncio + result = asyncio.run(vault._async.export_vault(output)) + console.print(f"[green]Exported[/green] {result['resource_count']} resources to {result['path']}") + + if __name__ == "__main__": app() diff --git a/src/qp_vault/config.py b/src/qp_vault/config.py index 02162eb..f8ca898 100644 --- a/src/qp_vault/config.py +++ b/src/qp_vault/config.py @@ -65,6 +65,7 @@ class VaultConfig(BaseModel): # Limits max_file_size_mb: int = 500 + max_resources_per_tenant: int | None = None # None = unlimited # Plugins plugins_dir: str | None = None diff --git a/src/qp_vault/integrations/fastapi_routes.py b/src/qp_vault/integrations/fastapi_routes.py index 5f59019..0b633b4 100644 --- a/src/qp_vault/integrations/fastapi_routes.py +++ b/src/qp_vault/integrations/fastapi_routes.py @@ -252,4 +252,59 @@ async def expiring(days: int = 90) -> dict[str, Any]: resources = await vault.expiring(days=days) return {"data": [r.model_dump() for r in resources], "meta": {"days": days}} + @router.get("/resources/{resource_id}/content") + async def get_content(resource_id: str) -> dict[str, Any]: + try: + text = await vault.get_content(resource_id) + except Exception as e: + raise _handle_error(e) from e + return {"data": {"content": text}, "meta": {}} + + @router.get("/resources/{resource_id}/provenance") + async def get_provenance(resource_id: str) -> dict[str, Any]: + records = await vault.get_provenance(resource_id) + return {"data": records, "meta": {"count": len(records)}} + + @router.get("/collections") + async def list_collections() -> dict[str, Any]: + colls = await vault.list_collections() + return {"data": colls, "meta": {"count": len(colls)}} + + @router.post("/collections") + async def create_collection(req: dict[str, Any]) -> dict[str, Any]: + result = await vault.create_collection(req.get("name", ""), description=req.get("description", "")) + return {"data": result, "meta": {}} + + @router.post("/search/faceted") + async def search_faceted(req: SearchRequest) -> dict[str, Any]: + as_of_date = date.fromisoformat(req.as_of) if req.as_of else None + result = await vault.search_with_facets( + req.query, + top_k=req.top_k, + trust_min=req.trust_min, + layer=req.layer, + as_of=as_of_date, + ) + return { + "data": [r.model_dump() for r in result["results"]], + "meta": {"total": result["total"], "facets": result["facets"]}, + } + + @router.post("/batch") + async def add_batch(req: dict[str, Any]) -> dict[str, Any]: + sources = req.get("sources", []) + trust = req.get("trust", "working") + tenant_id = req.get("tenant_id") + resources = await vault.add_batch( + [s.get("content", "") if isinstance(s, dict) else s for s in sources], + trust=trust, + tenant_id=tenant_id, + ) + return {"data": [r.model_dump() for r in resources], "meta": {"count": len(resources)}} + + @router.get("/export") + async def export_vault_endpoint(output: str = "vault_export.json") -> dict[str, Any]: + result = await vault.export_vault(output) + return {"data": result, "meta": {}} + return router diff --git a/src/qp_vault/storage/sqlite.py b/src/qp_vault/storage/sqlite.py index 5c9e83e..ab4875d 100644 --- a/src/qp_vault/storage/sqlite.py +++ b/src/qp_vault/storage/sqlite.py @@ -113,6 +113,8 @@ 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); +CREATE INDEX IF NOT EXISTS idx_resources_classification ON resources(data_classification); +CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(resource_type); """ _FTS_SCHEMA = """ diff --git a/src/qp_vault/vault.py b/src/qp_vault/vault.py index df70d93..3da56ab 100644 --- a/src/qp_vault/vault.py +++ b/src/qp_vault/vault.py @@ -322,6 +322,18 @@ async def add( f"({size_mb:.1f}MB provided)" ) + # Per-tenant quota check + if tenant_id and self.config.max_resources_per_tenant is not None: + from qp_vault.protocols import ResourceFilter + existing = await self._storage.list_resources( + ResourceFilter(tenant_id=tenant_id, limit=1, offset=self.config.max_resources_per_tenant) + ) + if existing: + raise VaultError( + f"Tenant {tenant_id} has reached the resource limit " + f"({self.config.max_resources_per_tenant})" + ) + # Strip null bytes from content (prevents storage/search corruption) text = text.replace("\x00", "") @@ -647,6 +659,37 @@ async def search( return paginated + async def search_with_facets( + self, + query: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Search with faceted results. + + Returns results plus facet counts by trust tier, resource type, + and data classification. + """ + results = await self.search(query, **kwargs) + + facets: dict[str, dict[str, int]] = { + "trust_tier": {}, + "resource_type": {}, + "data_classification": {}, + } + for r in results: + tier = r.trust_tier.value if hasattr(r.trust_tier, "value") else str(r.trust_tier) + facets["trust_tier"][tier] = facets["trust_tier"].get(tier, 0) + 1 + if r.resource_type: + facets["resource_type"][r.resource_type] = facets["resource_type"].get(r.resource_type, 0) + 1 + if r.data_classification: + facets["data_classification"][r.data_classification] = facets["data_classification"].get(r.data_classification, 0) + 1 + + return { + "results": results, + "total": len(results), + "facets": facets, + } + # --- Verification --- async def verify(self, resource_id: str | None = None) -> VerificationResult | VaultVerificationResult: