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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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"
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.10.0"
__version__ = "0.11.0"
__author__ = "Quantum Pipes Technologies, LLC"
__license__ = "Apache-2.0"

Expand Down
84 changes: 84 additions & 0 deletions src/qp_vault/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions src/qp_vault/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions src/qp_vault/integrations/fastapi_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/qp_vault/storage/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
43 changes: 43 additions & 0 deletions src/qp_vault/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")

Expand Down Expand Up @@ -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:
Expand Down
Loading