pramana-store 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pramana_store-0.0.1/.gitignore +15 -0
- pramana_store-0.0.1/PKG-INFO +8 -0
- pramana_store-0.0.1/pyproject.toml +23 -0
- pramana_store-0.0.1/src/pramana_store/__init__.py +0 -0
- pramana_store-0.0.1/src/pramana_store/apikeys.py +83 -0
- pramana_store-0.0.1/src/pramana_store/blob.py +134 -0
- pramana_store-0.0.1/src/pramana_store/plans.py +89 -0
- pramana_store-0.0.1/src/pramana_store/ports.py +96 -0
- pramana_store-0.0.1/src/pramana_store/postgres.py +462 -0
- pramana_store-0.0.1/src/pramana_store/schema.sql +201 -0
- pramana_store-0.0.1/tests/conftest.py +18 -0
- pramana_store-0.0.1/tests/test_apikeys.py +57 -0
- pramana_store-0.0.1/tests/test_blob.py +60 -0
- pramana_store-0.0.1/tests/test_postgres_repository.py +437 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pramana/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
web/node_modules/
|
|
7
|
+
web/dist/
|
|
8
|
+
# Secrets. The trailing slash this line used to have (`.env/`) matched only a
|
|
9
|
+
# *directory* named .env, never the file — which is how .env ended up committed
|
|
10
|
+
# in a1434d1 with a live Postgres password and S3 keys in it. Adding it here
|
|
11
|
+
# does not remove it from history: those credentials must be rotated, and the
|
|
12
|
+
# commit purged, separately.
|
|
13
|
+
.env
|
|
14
|
+
.env.*
|
|
15
|
+
!.env.example
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pramana-store"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"pramana-core",
|
|
7
|
+
"pramana-proto",
|
|
8
|
+
"psycopg[binary,pool]>=3.2",
|
|
9
|
+
"boto3>=1.34",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/pramana_store"]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.metadata]
|
|
20
|
+
allow-direct-references = true
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest"]
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""API-key resolution, shared by every service that authenticates one.
|
|
2
|
+
|
|
3
|
+
Lives here rather than in `services/api` because this package already owns the
|
|
4
|
+
`api_keys` table (schema.sql) and both `services/api` and `services/ingest`
|
|
5
|
+
already depend on it. The alternative — ingest re-implementing the lookup —
|
|
6
|
+
means two definitions of "is this key still valid", and the one that drifts is
|
|
7
|
+
the one that keeps accepting revoked keys.
|
|
8
|
+
|
|
9
|
+
Only the hash is ever stored, so a key's plaintext cannot be recovered from
|
|
10
|
+
this table; `hash_key` is the single definition of how a presented secret maps
|
|
11
|
+
onto a stored row.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from psycopg.rows import dict_row
|
|
21
|
+
|
|
22
|
+
from pramana_store.postgres import pooled_connect
|
|
23
|
+
|
|
24
|
+
# Every authenticated request paid its own round-trip here before doing any
|
|
25
|
+
# actual work — against a Postgres with real network latency (a pooler across
|
|
26
|
+
# a real hop; docs/plan.md §21.1 measured 525ms per query to Supabase) that was
|
|
27
|
+
# roughly half of every endpoint's total latency, just to answer a question
|
|
28
|
+
# that almost never changes between two requests a few seconds apart.
|
|
29
|
+
#
|
|
30
|
+
# Cost of caching it: a revoked key keeps authenticating for up to
|
|
31
|
+
# `_CACHE_TTL_S` after revocation, since revoke_api_key() only has a key_id,
|
|
32
|
+
# not the secret whose hash keys this cache, so it cannot evict on revoke.
|
|
33
|
+
# 60s is the accepted ceiling for that window; make revocation write-through
|
|
34
|
+
# if that stops being acceptable.
|
|
35
|
+
_CACHE_TTL_S = 60.0
|
|
36
|
+
_cache: dict[tuple[str, str], tuple[tuple[str, str] | None, float]] = {}
|
|
37
|
+
_cache_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
# Roles allowed to *write* events. An auditor key is read-only by design
|
|
40
|
+
# (docs/PROJECT_SCOPE.md §3: auditors see structure and divergences, never raw
|
|
41
|
+
# payloads) — handing one to an SDK would let it author the very trace its
|
|
42
|
+
# holder is only supposed to inspect.
|
|
43
|
+
WRITER_ROLES = frozenset({"admin", "engineer"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def hash_key(secret: str) -> str:
|
|
47
|
+
return hashlib.sha256(secret.encode()).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_key(conninfo: str, secret: str) -> tuple[str, str] | None:
|
|
51
|
+
"""Returns (tenant_id, role) for a live key, or None if it is unknown or revoked.
|
|
52
|
+
|
|
53
|
+
Cached for `_CACHE_TTL_S` (see module docstring for the revocation-window
|
|
54
|
+
tradeoff this accepts). A miss (unknown/bad key) is cached too — a client
|
|
55
|
+
hammering a stale key would otherwise spend a full round-trip per attempt.
|
|
56
|
+
"""
|
|
57
|
+
key_hash = hash_key(secret)
|
|
58
|
+
cache_key = (conninfo, key_hash) # tests point different conninfos at the same hash
|
|
59
|
+
now = time.monotonic()
|
|
60
|
+
with _cache_lock:
|
|
61
|
+
cached = _cache.get(cache_key)
|
|
62
|
+
if cached is not None and cached[1] > now:
|
|
63
|
+
return cached[0]
|
|
64
|
+
|
|
65
|
+
with pooled_connect(conninfo, row_factory=dict_row) as conn:
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"SELECT tenant_id, role FROM api_keys WHERE hash = %s AND revoked_at IS NULL",
|
|
68
|
+
(key_hash,),
|
|
69
|
+
).fetchone()
|
|
70
|
+
result = (str(row["tenant_id"]), str(row["role"])) if row else None
|
|
71
|
+
|
|
72
|
+
with _cache_lock:
|
|
73
|
+
_cache[cache_key] = (result, now + _CACHE_TTL_S)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def invalidate(conninfo: str, key_hash: str) -> None:
|
|
78
|
+
"""Evict one cached resolution immediately — call this from the revoke
|
|
79
|
+
path so a revoked key stops working right away instead of riding out
|
|
80
|
+
`_CACHE_TTL_S`. Safe to call for a hash that was never cached.
|
|
81
|
+
"""
|
|
82
|
+
with _cache_lock:
|
|
83
|
+
_cache.pop((conninfo, key_hash), None)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
|
|
11
|
+
from pramana_store.ports import BlobStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _key(data: bytes) -> str:
|
|
15
|
+
return hashlib.sha256(data).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# One boto3 client per (endpoint, region) — request handlers construct a
|
|
19
|
+
# fresh S3BlobStore per call (it's tenant-scoped, tenant varies per
|
|
20
|
+
# request), but the underlying client is stateless w.r.t. tenant and
|
|
21
|
+
# reopening it every time paid a full TLS handshake per request against a
|
|
22
|
+
# remote endpoint (Supabase Storage) that a local MinIO/mock never surfaced.
|
|
23
|
+
_S3_CLIENTS: dict[tuple[str | None, str | None], Any] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _s3_client(endpoint_url: str | None, region_name: str | None) -> Any:
|
|
27
|
+
key = (endpoint_url, region_name)
|
|
28
|
+
client = _S3_CLIENTS.get(key)
|
|
29
|
+
if client is None:
|
|
30
|
+
# region_name: SigV4 (what every S3-compatible provider uses,
|
|
31
|
+
# including Supabase Storage) signs requests with a region baked in
|
|
32
|
+
# — omitting it relies on boto3/AWS_DEFAULT_REGION being set, which a
|
|
33
|
+
# fresh environment usually hasn't.
|
|
34
|
+
#
|
|
35
|
+
# max_pool_connections: botocore defaults to 10, which throttled the
|
|
36
|
+
# payloads-batch route's concurrent blob fetches (docs/plan.md §22) —
|
|
37
|
+
# the route fires up to 16 GETs in parallel, but only 10 could hold a
|
|
38
|
+
# pooled HTTP connection at once, so the rest queued behind them in
|
|
39
|
+
# waves instead of actually running concurrently. Raised to comfortably
|
|
40
|
+
# cover that batch size.
|
|
41
|
+
from botocore.config import Config
|
|
42
|
+
|
|
43
|
+
client = _S3_CLIENTS[key] = boto3.client(
|
|
44
|
+
"s3", endpoint_url=endpoint_url, region_name=region_name, config=Config(max_pool_connections=32)
|
|
45
|
+
)
|
|
46
|
+
return client
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class S3BlobStore:
|
|
50
|
+
"""Phase 0 `BlobStore` (docs/plan.md §4) — MinIO locally, S3 in cloud,
|
|
51
|
+
same code path both ways (docs/plan.md §2).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
bucket: str,
|
|
57
|
+
endpoint_url: str | None = None,
|
|
58
|
+
region_name: str | None = None,
|
|
59
|
+
tenant_id: str = "",
|
|
60
|
+
):
|
|
61
|
+
if not tenant_id:
|
|
62
|
+
raise ValueError("tenant_id is required")
|
|
63
|
+
self._bucket = bucket
|
|
64
|
+
self._tenant_id = tenant_id
|
|
65
|
+
self._s3 = _s3_client(endpoint_url, region_name)
|
|
66
|
+
|
|
67
|
+
def _object_key(self, key: str) -> str:
|
|
68
|
+
return f"{self._tenant_id}/blobs/{key}"
|
|
69
|
+
|
|
70
|
+
def put(self, data: bytes) -> str:
|
|
71
|
+
key = _key(data)
|
|
72
|
+
self._s3.put_object(Bucket=self._bucket, Key=self._object_key(key), Body=data)
|
|
73
|
+
return key
|
|
74
|
+
|
|
75
|
+
def get(self, key: str) -> bytes:
|
|
76
|
+
obj = self._s3.get_object(Bucket=self._bucket, Key=self._object_key(key))
|
|
77
|
+
return bytes(obj["Body"].read()) # boto3 has no usable stubs; .read() types as Any
|
|
78
|
+
|
|
79
|
+
def delete(self, key: str) -> None:
|
|
80
|
+
self._s3.delete_object(Bucket=self._bucket, Key=self._object_key(key))
|
|
81
|
+
# S3's delete_object is already idempotent (204 whether or not the key
|
|
82
|
+
# existed) — no not-found error to translate here.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LocalBlobStore:
|
|
86
|
+
"""Test adapter named explicitly in docs/plan.md §4 ("LocalBlobStore
|
|
87
|
+
(tests)"). Filesystem-backed, same content-addressing scheme.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, root: Path | str, tenant_id: str = ""):
|
|
91
|
+
if not tenant_id:
|
|
92
|
+
raise ValueError("tenant_id is required")
|
|
93
|
+
self._root = Path(root) / tenant_id / "blobs"
|
|
94
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
def put(self, data: bytes) -> str:
|
|
97
|
+
key = _key(data)
|
|
98
|
+
path = self._root / key
|
|
99
|
+
if not path.exists():
|
|
100
|
+
path.write_bytes(data)
|
|
101
|
+
return key
|
|
102
|
+
|
|
103
|
+
def get(self, key: str) -> bytes:
|
|
104
|
+
path = self._root / key
|
|
105
|
+
if not path.exists():
|
|
106
|
+
raise KeyError(key)
|
|
107
|
+
return path.read_bytes()
|
|
108
|
+
|
|
109
|
+
def delete(self, key: str) -> None:
|
|
110
|
+
path = self._root / key
|
|
111
|
+
try:
|
|
112
|
+
path.unlink()
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
raise KeyError(key) from None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def blob_store_factory_from_env() -> Callable[[str], BlobStore]:
|
|
118
|
+
"""Shared by `api` and `ingest` `__main__` boot code so both point at the
|
|
119
|
+
same backend from the same env vars — local disk only works when both
|
|
120
|
+
services share a filesystem (e.g. the `make dev` bind mount); a real
|
|
121
|
+
multi-machine deployment needs `PRAMANA_BLOB_BACKEND=s3`.
|
|
122
|
+
"""
|
|
123
|
+
backend = os.environ.get("PRAMANA_BLOB_BACKEND", "local")
|
|
124
|
+
if backend == "s3":
|
|
125
|
+
bucket = os.environ["PRAMANA_S3_BUCKET"]
|
|
126
|
+
endpoint_url = os.environ.get("PRAMANA_S3_ENDPOINT_URL")
|
|
127
|
+
region_name = os.environ.get("PRAMANA_S3_REGION")
|
|
128
|
+
return lambda tenant_id: S3BlobStore(
|
|
129
|
+
bucket, endpoint_url=endpoint_url, region_name=region_name, tenant_id=tenant_id
|
|
130
|
+
)
|
|
131
|
+
if backend != "local":
|
|
132
|
+
raise ValueError(f"unknown PRAMANA_BLOB_BACKEND: {backend!r}")
|
|
133
|
+
blob_dir = os.environ.get("PRAMANA_API_BLOB_DIR", ".pramana/blobs")
|
|
134
|
+
return lambda tenant_id: LocalBlobStore(blob_dir, tenant_id=tenant_id)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Plan limits (docs/PROJECT_SCOPE.md §6), shared by every service that
|
|
2
|
+
needs to know what a tenant's plan allows.
|
|
3
|
+
|
|
4
|
+
Lives here, not in `pramana_api.billing`, specifically so `services/ingest`
|
|
5
|
+
can enforce the monthly event cap without depending on the whole api
|
|
6
|
+
service (accounts, Stripe, team management) — the same reason
|
|
7
|
+
`pramana_store.apikeys` exists instead of ingest importing `pramana_api`'s
|
|
8
|
+
auth module. `pramana_api.billing` re-exports everything here and adds the
|
|
9
|
+
write-side operations (`set_plan`, seat checks, Stripe linkage) that only
|
|
10
|
+
the api service needs.
|
|
11
|
+
|
|
12
|
+
ponytail: a plain dict, not a Plan class hierarchy — a handful of plans with
|
|
13
|
+
the same five fields each doesn't earn an abstraction yet.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pramana_store.postgres import pooled_connect
|
|
22
|
+
|
|
23
|
+
DEFAULT_PLAN = "free"
|
|
24
|
+
|
|
25
|
+
PLANS: dict[str, dict[str, Any]] = {
|
|
26
|
+
"free": {
|
|
27
|
+
"display_name": "Free",
|
|
28
|
+
"price_usd_per_month": 0,
|
|
29
|
+
"evidence_export": False,
|
|
30
|
+
"max_seats": 3,
|
|
31
|
+
"retention_days": 30,
|
|
32
|
+
# Nothing else bounds ingest volume for a free tenant — the
|
|
33
|
+
# per-tenant rate limiter (services/ingest) is a flat abuse-prevention
|
|
34
|
+
# ceiling, the same for every plan, not a billing differentiator. A
|
|
35
|
+
# single chatty free-tier key could otherwise ingest indefinitely at
|
|
36
|
+
# that ceiling for the whole 30-day retention window at real
|
|
37
|
+
# infrastructure cost and zero revenue. This is the actual cost
|
|
38
|
+
# driver (Postgres rows, S3 blobs, compute) that flat-tier pricing
|
|
39
|
+
# otherwise leaves completely unbounded.
|
|
40
|
+
"monthly_event_cap": 50_000,
|
|
41
|
+
},
|
|
42
|
+
"pro": {
|
|
43
|
+
"display_name": "Pro",
|
|
44
|
+
"price_usd_per_month": 99,
|
|
45
|
+
"evidence_export": True,
|
|
46
|
+
"max_seats": 10,
|
|
47
|
+
"retention_days": 365,
|
|
48
|
+
"monthly_event_cap": 2_000_000,
|
|
49
|
+
},
|
|
50
|
+
"enterprise": {
|
|
51
|
+
"display_name": "Enterprise",
|
|
52
|
+
# None means "contact sales" / "unlimited", not zero — same
|
|
53
|
+
# convention as max_seats/retention_days below.
|
|
54
|
+
"price_usd_per_month": None,
|
|
55
|
+
"evidence_export": True,
|
|
56
|
+
"max_seats": None,
|
|
57
|
+
"retention_days": None,
|
|
58
|
+
"monthly_event_cap": None,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnknownPlanError(ValueError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class PlanLimits:
|
|
69
|
+
plan: str
|
|
70
|
+
display_name: str
|
|
71
|
+
price_usd_per_month: int | None
|
|
72
|
+
evidence_export: bool
|
|
73
|
+
max_seats: int | None
|
|
74
|
+
retention_days: int | None
|
|
75
|
+
monthly_event_cap: int | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def plan_limits(plan: str) -> PlanLimits:
|
|
79
|
+
if plan not in PLANS:
|
|
80
|
+
raise UnknownPlanError(f"plan must be one of {sorted(PLANS)}, got {plan!r}")
|
|
81
|
+
return PlanLimits(plan=plan, **PLANS[plan])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_plan(conninfo: str, tenant_id: str) -> str:
|
|
85
|
+
with pooled_connect(conninfo) as conn:
|
|
86
|
+
row = conn.execute("SELECT plan FROM tenants WHERE id = %s", (tenant_id,)).fetchone()
|
|
87
|
+
if row is None:
|
|
88
|
+
raise ValueError(f"no such tenant: {tenant_id!r}")
|
|
89
|
+
return str(row[0])
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Two of the three seams named in docs/plan.md §4. Bare-noun Protocols, no
|
|
2
|
+
`I`/`Base` prefix. Each has (or is committed to have) >=2 real implementations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from pramana_proto.v1.event_pb2 import Event
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventRepository(Protocol):
|
|
13
|
+
"""Constructed with tenant_id — there is no query method that can forget
|
|
14
|
+
the tenant filter (docs/plan.md §4, "multi-tenancy is enforced structurally").
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def append_if_new(self, event: Event) -> tuple[Event, bool]:
|
|
18
|
+
"""Insert (tenant_id, event_id) if new; seals the hash chain itself
|
|
19
|
+
(prev_hash/this_hash are computed here, not supplied by the caller —
|
|
20
|
+
docs/plan.md D2). Returns (stored_event, was_new). On conflict,
|
|
21
|
+
returns the existing row unchanged (idempotent no-op).
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def chain_head(self, trace_id: str) -> str:
|
|
26
|
+
"""Current this_hash at the head of trace_id's chain, or "" (genesis)."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def monthly_usage(self) -> int:
|
|
30
|
+
"""Events accepted for this tenant in the current UTC month."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def get_by_call_site(
|
|
34
|
+
self, trace_id: str, agent_id: str, call_site_id: str, call_site_ordinal: int
|
|
35
|
+
) -> Event | None: ...
|
|
36
|
+
|
|
37
|
+
def list_by_trace(self, trace_id: str) -> list[Event]: ...
|
|
38
|
+
|
|
39
|
+
def record_divergence(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
trace_id: str,
|
|
43
|
+
agent_id: str,
|
|
44
|
+
call_site_id: str,
|
|
45
|
+
call_site_ordinal: int,
|
|
46
|
+
expected_input_hash: str,
|
|
47
|
+
actual_input_hash: str,
|
|
48
|
+
actual_input_ref: str = "",
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Divergence is a product feature (docs/LLD.md §4b), not a fault —
|
|
51
|
+
recorded regardless of policy (HARD_STOP still raises after this)."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def list_divergences(self, trace_id: str) -> list[dict[str, Any]]: ...
|
|
55
|
+
|
|
56
|
+
def get_by_seq(self, trace_id: str, logical_seq: int, agent_id: str | None = None) -> Event | None: ...
|
|
57
|
+
|
|
58
|
+
def list_traces(self, limit: int = 200, before_started_at_ns: int | None = None) -> list[dict[str, Any]]: ...
|
|
59
|
+
|
|
60
|
+
def list_by_trace_range(self, trace_id: str, from_seq: int, to_seq: int) -> list[Event]: ...
|
|
61
|
+
|
|
62
|
+
def save_evidence_bundle(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
bundle_id: str,
|
|
66
|
+
trace_id: str,
|
|
67
|
+
from_seq: int,
|
|
68
|
+
to_seq: int,
|
|
69
|
+
merkle_root: str,
|
|
70
|
+
signature: str,
|
|
71
|
+
blob_ref: str,
|
|
72
|
+
) -> None: ...
|
|
73
|
+
|
|
74
|
+
def get_evidence_bundle(self, bundle_id: str) -> dict[str, Any] | None: ...
|
|
75
|
+
|
|
76
|
+
def list_evidence_bundles(self, trace_id: str) -> list[dict[str, Any]]: ...
|
|
77
|
+
|
|
78
|
+
def get_agent_graph(self, trace_id: str | None = None) -> list[dict[str, Any]]: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BlobStore(Protocol):
|
|
82
|
+
"""Content-addressed: key = sha256(bytes). Put is idempotent."""
|
|
83
|
+
|
|
84
|
+
def put(self, data: bytes) -> str:
|
|
85
|
+
"""Returns the content-addressed key."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def get(self, key: str) -> bytes: ...
|
|
89
|
+
|
|
90
|
+
def delete(self, key: str) -> None:
|
|
91
|
+
"""Retention (docs/plan.md §21.3) calls this only after confirming no
|
|
92
|
+
surviving event still references `key` — safe to raise KeyError /
|
|
93
|
+
FileNotFoundError on a missing key, the caller treats that as already
|
|
94
|
+
deleted rather than an error.
|
|
95
|
+
"""
|
|
96
|
+
...
|