pramana-core 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_core-0.0.1/.gitignore +15 -0
- pramana_core-0.0.1/PKG-INFO +6 -0
- pramana_core-0.0.1/pyproject.toml +18 -0
- pramana_core-0.0.1/src/pramana_core/__init__.py +0 -0
- pramana_core-0.0.1/src/pramana_core/chain.py +36 -0
- pramana_core-0.0.1/src/pramana_core/errors.py +24 -0
- pramana_core-0.0.1/src/pramana_core/event_json.py +19 -0
- pramana_core-0.0.1/src/pramana_core/hashing.py +43 -0
- pramana_core-0.0.1/src/pramana_core/ids.py +19 -0
- pramana_core-0.0.1/src/pramana_core/logging.py +107 -0
- pramana_core-0.0.1/src/pramana_core/merkle.py +41 -0
- pramana_core-0.0.1/src/pramana_core/metrics.py +66 -0
- pramana_core-0.0.1/src/pramana_core/sequencer.py +61 -0
- pramana_core-0.0.1/src/pramana_core/signing.py +50 -0
- pramana_core-0.0.1/src/pramana_core/vclock.py +49 -0
- pramana_core-0.0.1/src/pramana_core/wsgi.py +146 -0
- pramana_core-0.0.1/tests/test_chain.py +42 -0
- pramana_core-0.0.1/tests/test_hashing.py +31 -0
- pramana_core-0.0.1/tests/test_ids.py +19 -0
- pramana_core-0.0.1/tests/test_merkle.py +42 -0
- pramana_core-0.0.1/tests/test_metrics.py +32 -0
- pramana_core-0.0.1/tests/test_sequencer.py +128 -0
- pramana_core-0.0.1/tests/test_signing.py +26 -0
- pramana_core-0.0.1/tests/test_signing_extra.py +6 -0
- pramana_core-0.0.1/tests/test_vclock.py +74 -0
- pramana_core-0.0.1/tests/test_wsgi.py +139 -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,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pramana-core"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = ["pramana-proto", "cryptography>=42"]
|
|
6
|
+
|
|
7
|
+
[build-system]
|
|
8
|
+
requires = ["hatchling"]
|
|
9
|
+
build-backend = "hatchling.build"
|
|
10
|
+
|
|
11
|
+
[tool.hatch.build.targets.wheel]
|
|
12
|
+
packages = ["src/pramana_core"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.metadata]
|
|
15
|
+
allow-direct-references = true
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["pytest"]
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Server-side hash chain (docs/plan.md §3.4, D2). The SDK never computes
|
|
2
|
+
prev_hash/this_hash — only the log writer (services/ingest) does, using these
|
|
3
|
+
functions, under a per-trace lock.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pramana_proto.v1.event_pb2 import Event
|
|
11
|
+
|
|
12
|
+
from pramana_core.event_json import event_to_dict
|
|
13
|
+
from pramana_core.hashing import canonical_json_bytes, sha256_hex
|
|
14
|
+
|
|
15
|
+
GENESIS_HASH = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def event_fields_for_hash(event_dict: dict[str, Any], prev_hash: str) -> dict[str, Any]:
|
|
19
|
+
"""The exact recipe verify-cli must replicate against a JSON-dict-shaped
|
|
20
|
+
event (docs/plan.md M4): drop this_hash, override prev_hash, hash the
|
|
21
|
+
rest. Split out from `compute_this_hash` so both the live proto path and
|
|
22
|
+
a pure-JSON verifier hash identically off one definition.
|
|
23
|
+
"""
|
|
24
|
+
fields = dict(event_dict)
|
|
25
|
+
fields.pop("this_hash", None)
|
|
26
|
+
fields["prev_hash"] = prev_hash
|
|
27
|
+
return fields
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compute_this_hash(event: Event, prev_hash: str) -> str:
|
|
31
|
+
"""sha256(canonical(event minus this_hash)), per docs/LLD.md §6.
|
|
32
|
+
prev_hash is taken as a parameter (not read from `event`) so the caller
|
|
33
|
+
cannot accidentally hash a stale/forged prev_hash field.
|
|
34
|
+
"""
|
|
35
|
+
fields = event_fields_for_hash(event_to_dict(event), prev_hash)
|
|
36
|
+
return sha256_hex(canonical_json_bytes(fields))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Domain errors. `Divergence` is not an Error subclass by accident — it is a
|
|
2
|
+
signal (docs/LLD.md §4b: "divergence is a product feature"), not a fault.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PramanaError(Exception):
|
|
9
|
+
"""Base for genuine faults."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReplayError(PramanaError):
|
|
13
|
+
"""Replay cannot proceed at all (e.g. no recorded event at this position)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Divergence(Exception):
|
|
17
|
+
"""Replayed input != recorded input. Raised only under HARD_STOP policy;
|
|
18
|
+
under CONTINUE_AND_FLAG it is recorded, not raised.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, call_site_id: str, call_site_ordinal: int, message: str = ""):
|
|
22
|
+
self.call_site_id = call_site_id
|
|
23
|
+
self.call_site_ordinal = call_site_ordinal
|
|
24
|
+
super().__init__(message or f"divergence at {call_site_id}#{call_site_ordinal}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The one Event-to-dict conversion (docs/plan.md §3.1: "JSON API keys ==
|
|
2
|
+
proto field names"). Shared by services/api and services/evidence so an
|
|
3
|
+
evidence bundle's event shape is byte-identical to what the query API
|
|
4
|
+
already serves — verify-cli re-hashes this exact shape, so any drift here
|
|
5
|
+
would make a legitimate bundle look tampered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from google.protobuf.json_format import MessageToDict
|
|
13
|
+
from pramana_proto.v1.event_pb2 import Event
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def event_to_dict(event: Event) -> dict[str, Any]:
|
|
17
|
+
# dict(...): MessageToDict types as Any (google.protobuf has no stubs
|
|
18
|
+
# here) — the wrapper gives mypy a concrete dict[Any, Any] to return.
|
|
19
|
+
return dict(MessageToDict(event, preserving_proto_field_name=True, always_print_fields_with_no_presence=True))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The one canonical-bytes + hash primitive. Shared by the SDK canonicalizer
|
|
2
|
+
(input_hash, docs/plan.md §3.2) and the server-side chain hasher (this_hash,
|
|
3
|
+
docs/plan.md §3.4). Do not duplicate this logic anywhere else.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NonFiniteValueError(ValueError):
|
|
15
|
+
"""Raised when a NaN/Infinity value would otherwise be silently coerced."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _reject_non_finite(obj: Any) -> Any:
|
|
19
|
+
if isinstance(obj, float) and not math.isfinite(obj):
|
|
20
|
+
raise NonFiniteValueError(f"non-finite float in canonicalized payload: {obj!r}")
|
|
21
|
+
if isinstance(obj, dict):
|
|
22
|
+
for k, v in obj.items():
|
|
23
|
+
_reject_non_finite(k)
|
|
24
|
+
_reject_non_finite(v)
|
|
25
|
+
elif isinstance(obj, (list, tuple)):
|
|
26
|
+
for v in obj:
|
|
27
|
+
_reject_non_finite(v)
|
|
28
|
+
return obj
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def canonical_json_bytes(obj: Any) -> bytes:
|
|
32
|
+
"""Steps 3-5 of docs/plan.md §3.2: sort_keys json dumps, reject NaN/Infinity,
|
|
33
|
+
utf-8 encode. Redaction and per-vendor exclusion (steps 1-2) are the caller's
|
|
34
|
+
job — they are adapter-specific, not generic.
|
|
35
|
+
"""
|
|
36
|
+
_reject_non_finite(obj)
|
|
37
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
38
|
+
"utf-8"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sha256_hex(data: bytes) -> str:
|
|
43
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""uuid v7 (time-sortable), per docs/LLD.md §1 event_id. Python's stdlib gains
|
|
2
|
+
`uuid.uuid7()` in 3.14; this workspace targets 3.12+, so a small stdlib-only
|
|
3
|
+
implementation (RFC 9562) stands in until the floor is raised.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def uuid7() -> str:
|
|
14
|
+
ts_ms = time.time_ns() // 1_000_000
|
|
15
|
+
rand = os.urandom(10)
|
|
16
|
+
b = bytearray(ts_ms.to_bytes(6, "big") + rand)
|
|
17
|
+
b[6] = (b[6] & 0x0F) | 0x70 # version 7
|
|
18
|
+
b[8] = (b[8] & 0x3F) | 0x80 # RFC 4122 variant
|
|
19
|
+
return str(uuid.UUID(bytes=bytes(b)))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Structured JSON request logging, shared by every http.server-based service.
|
|
2
|
+
|
|
3
|
+
Both services overrode `log_message` to a no-op and imported no logging
|
|
4
|
+
library at all (docs/plan.md §21.6) — silence was fine until something broke,
|
|
5
|
+
at which point there was nothing to read. One JSON object per line on stdout
|
|
6
|
+
is enough: every container platform (Cloud Run, ECS, plain Docker) already
|
|
7
|
+
collects stdout, and Cloud-Logging-style backends parse JSON lines into
|
|
8
|
+
queryable fields for free — no vendor, no dependency, no cost.
|
|
9
|
+
|
|
10
|
+
Never feed this payload contents, prompts, or the Authorization header —
|
|
11
|
+
request_id, tenant_id, method, path, status, and duration_ms are the fields a
|
|
12
|
+
caller should set.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _JsonFormatter(logging.Formatter):
|
|
26
|
+
def __init__(self, service: str) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._service = service
|
|
29
|
+
|
|
30
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
31
|
+
payload: dict[str, Any] = {
|
|
32
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
|
33
|
+
"level": record.levelname,
|
|
34
|
+
"service": self._service,
|
|
35
|
+
"message": record.getMessage(),
|
|
36
|
+
}
|
|
37
|
+
for key in ("request_id", "tenant_id", "method", "path", "status", "duration_ms", "exception"):
|
|
38
|
+
value = getattr(record, key, None)
|
|
39
|
+
if value is not None:
|
|
40
|
+
payload[key] = value
|
|
41
|
+
return json.dumps(payload)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def configure_json_logging(service: str) -> logging.Logger:
|
|
45
|
+
"""Idempotent: safe to call more than once (tests, or a re-exec) without
|
|
46
|
+
stacking duplicate handlers and doubling every line.
|
|
47
|
+
"""
|
|
48
|
+
logger = logging.getLogger(service)
|
|
49
|
+
if not logger.handlers:
|
|
50
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
51
|
+
handler.setFormatter(_JsonFormatter(service))
|
|
52
|
+
logger.addHandler(handler)
|
|
53
|
+
logger.setLevel(logging.INFO)
|
|
54
|
+
logger.propagate = False
|
|
55
|
+
return logger
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RequestLoggingMixin:
|
|
59
|
+
"""Mix into a `BaseHTTPRequestHandler` subclass, ahead of it in the MRO,
|
|
60
|
+
so this class's `handle_one_request`/`log_message` are the ones that run.
|
|
61
|
+
|
|
62
|
+
Set the class attribute `logger` (from `configure_json_logging`) on the
|
|
63
|
+
concrete handler. Set `self._log_tenant = tenant_id` once auth resolves,
|
|
64
|
+
if the route has a tenant, so it lands on the request's own log line
|
|
65
|
+
instead of a separate one.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
logger: logging.Logger
|
|
69
|
+
|
|
70
|
+
def handle_one_request(self) -> None:
|
|
71
|
+
# Runs once per request even on a keep-alive connection serving
|
|
72
|
+
# several — resets the clock and identity per request rather than
|
|
73
|
+
# for the whole TCP connection.
|
|
74
|
+
self._req_start = time.monotonic()
|
|
75
|
+
self._log_tenant: str | None = None
|
|
76
|
+
self._req_id = uuid.uuid4().hex[:12]
|
|
77
|
+
super().handle_one_request() # type: ignore[misc]
|
|
78
|
+
|
|
79
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
80
|
+
# BaseHTTPRequestHandler.log_request calls this as
|
|
81
|
+
# log_message('"%s" %s %s', requestline, code, size) — args[1] is the
|
|
82
|
+
# status code stdlib already parsed out for us.
|
|
83
|
+
status = args[1] if len(args) > 1 else None
|
|
84
|
+
duration_ms = round((time.monotonic() - getattr(self, "_req_start", time.monotonic())) * 1000, 1)
|
|
85
|
+
self.logger.info(
|
|
86
|
+
"request",
|
|
87
|
+
extra={
|
|
88
|
+
"request_id": getattr(self, "_req_id", None),
|
|
89
|
+
"tenant_id": getattr(self, "_log_tenant", None),
|
|
90
|
+
"method": getattr(self, "command", None),
|
|
91
|
+
"path": getattr(self, "path", None),
|
|
92
|
+
"status": status,
|
|
93
|
+
"duration_ms": duration_ms,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def log_exception(self, exc: BaseException) -> None:
|
|
98
|
+
self.logger.error(
|
|
99
|
+
"unhandled exception",
|
|
100
|
+
extra={
|
|
101
|
+
"request_id": getattr(self, "_req_id", None),
|
|
102
|
+
"tenant_id": getattr(self, "_log_tenant", None),
|
|
103
|
+
"method": getattr(self, "command", None),
|
|
104
|
+
"path": getattr(self, "path", None),
|
|
105
|
+
"exception": type(exc).__name__,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Merkle root over a segment's this_hashes (docs/LLD.md §6, docs/plan.md M4).
|
|
2
|
+
|
|
3
|
+
RFC 6962 (Certificate Transparency) tree shape, not the naive "duplicate the
|
|
4
|
+
last node when odd" construction — the naive version has a known
|
|
5
|
+
second-preimage weakness (a non-leaf node's hash can be replayed as if it
|
|
6
|
+
were a leaf). RFC 6962's domain-separated prefixes (0x00 leaf, 0x01 node)
|
|
7
|
+
close that off.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _leaf_hash(data: bytes) -> bytes:
|
|
16
|
+
return hashlib.sha256(b"\x00" + data).digest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _node_hash(left: bytes, right: bytes) -> bytes:
|
|
20
|
+
return hashlib.sha256(b"\x01" + left + right).digest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mth(leaves: list[bytes]) -> bytes:
|
|
24
|
+
n = len(leaves)
|
|
25
|
+
if n == 0:
|
|
26
|
+
return hashlib.sha256(b"").digest()
|
|
27
|
+
if n == 1:
|
|
28
|
+
return leaves[0]
|
|
29
|
+
k = 1
|
|
30
|
+
while k * 2 < n:
|
|
31
|
+
k *= 2
|
|
32
|
+
return _node_hash(_mth(leaves[:k]), _mth(leaves[k:]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def merkle_root(leaf_hashes_hex: list[str]) -> str:
|
|
36
|
+
"""leaf_hashes_hex are the pre-existing this_hash hex strings (already
|
|
37
|
+
sha256 digests); this hashes them again as RFC 6962 leaves — the input
|
|
38
|
+
to a leaf is arbitrary bytes, not necessarily unhashed data.
|
|
39
|
+
"""
|
|
40
|
+
leaves = [_leaf_hash(bytes.fromhex(h)) for h in leaf_hashes_hex]
|
|
41
|
+
return _mth(leaves).hex()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""docs/plan.md §5.2 / §12 item 4: `pramana_<component>_<metric>_<unit>`
|
|
2
|
+
naming, wired to something real for the first time. A Prometheus text
|
|
3
|
+
exposition writer, not the `prometheus_client` dependency — the format is a
|
|
4
|
+
handful of lines (https://prometheus.io/docs/instrumenting/exposition_formats/)
|
|
5
|
+
and every other transport in this repo is stdlib `http.server`; a dependency
|
|
6
|
+
buys nothing here that hand-rolling doesn't already give for free.
|
|
7
|
+
|
|
8
|
+
One process-wide registry per process (ingest and api each import this
|
|
9
|
+
module and get their own, since they're separate processes) — a counter
|
|
10
|
+
survives for the life of the process, same as any in-memory Prometheus
|
|
11
|
+
client would give you.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
_lock = threading.Lock()
|
|
19
|
+
_counters: dict[str, Counter] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Counter:
|
|
23
|
+
"""Monotonic counter, thread-safe — both servers here are
|
|
24
|
+
ThreadingHTTPServer, so increments can race from concurrent requests.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str, help_text: str):
|
|
28
|
+
self.name = name
|
|
29
|
+
self.help_text = help_text
|
|
30
|
+
self._value = 0
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def inc(self, amount: int = 1) -> None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._value += amount
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def value(self) -> int:
|
|
39
|
+
return self._value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def counter(name: str, help_text: str) -> Counter:
|
|
43
|
+
"""Registers `name` on first call; returns the same Counter on every
|
|
44
|
+
later call with that name — call sites don't need to hold onto a module-
|
|
45
|
+
level reference themselves, though most will for the `.inc()` call.
|
|
46
|
+
"""
|
|
47
|
+
with _lock:
|
|
48
|
+
existing = _counters.get(name)
|
|
49
|
+
if existing is not None:
|
|
50
|
+
return existing
|
|
51
|
+
c = Counter(name, help_text)
|
|
52
|
+
_counters[name] = c
|
|
53
|
+
return c
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_text() -> bytes:
|
|
57
|
+
"""Prometheus text exposition format. Just counters today — a gauge or
|
|
58
|
+
histogram type is a `# TYPE` line away whenever one is actually needed."""
|
|
59
|
+
with _lock:
|
|
60
|
+
snapshot = list(_counters.values())
|
|
61
|
+
lines = []
|
|
62
|
+
for c in snapshot:
|
|
63
|
+
lines.append(f"# HELP {c.name} {c.help_text}")
|
|
64
|
+
lines.append(f"# TYPE {c.name} counter")
|
|
65
|
+
lines.append(f"{c.name} {c.value}")
|
|
66
|
+
return ("\n".join(lines) + "\n").encode()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Merge multiple agents' event streams into one deterministic total order
|
|
2
|
+
consistent with the vector-clock partial order (docs/LLD.md §5, docs/plan.md
|
|
3
|
+
repo layout: "sequencer — inline in ingest until then" — this runs at
|
|
4
|
+
replay/query time, not the write path; see services/ingest for why the
|
|
5
|
+
write path stays arrival-order).
|
|
6
|
+
|
|
7
|
+
Kahn's algorithm: topological sort on the happens-before DAG, tie-broken by
|
|
8
|
+
(trace_id, agent_id, logical_seq) whenever multiple events are simultaneously
|
|
9
|
+
"ready" (docs/LLD.md §5: "tie-break by (trace_id, agent_id, logical_seq) for
|
|
10
|
+
determinism").
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pramana_proto.v1.event_pb2 import Event
|
|
16
|
+
|
|
17
|
+
from pramana_core.vclock import VectorClock
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _tie_break_key(event: Event) -> tuple[str, str, int]:
|
|
21
|
+
return (event.trace_id, event.agent_id, event.logical_seq)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def total_order(events: list[Event]) -> list[Event]:
|
|
25
|
+
"""`events` are anything with `.vclock` (packed bytes), `.trace_id`,
|
|
26
|
+
`.agent_id`, `.logical_seq` — i.e. proto Event objects. Pairwise
|
|
27
|
+
happens-before is used to build edges: O(n^2) comparisons, which is fine
|
|
28
|
+
at the scale a single trace's events actually reach; optimize only if a
|
|
29
|
+
benchmark says otherwise (docs/plan.md §3.6's own standard).
|
|
30
|
+
"""
|
|
31
|
+
n = len(events)
|
|
32
|
+
clocks = [VectorClock.from_bytes(e.vclock) for e in events]
|
|
33
|
+
|
|
34
|
+
# successors[i] = set of j such that i happens-before j
|
|
35
|
+
successors: list[set[int]] = [set() for _ in range(n)]
|
|
36
|
+
in_degree = [0] * n
|
|
37
|
+
for i in range(n):
|
|
38
|
+
for j in range(n):
|
|
39
|
+
if i != j and clocks[i].happens_before(clocks[j]):
|
|
40
|
+
successors[i].add(j)
|
|
41
|
+
in_degree[j] += 1
|
|
42
|
+
|
|
43
|
+
remaining = set(range(n))
|
|
44
|
+
ordered: list[Event] = []
|
|
45
|
+
while remaining:
|
|
46
|
+
ready = sorted(
|
|
47
|
+
(i for i in remaining if in_degree[i] == 0),
|
|
48
|
+
key=lambda i: _tie_break_key(events[i]),
|
|
49
|
+
)
|
|
50
|
+
if not ready:
|
|
51
|
+
# A cycle would mean corrupted/inconsistent vclocks (e.g. a
|
|
52
|
+
# MSG_RECV that never merged its sender's clock). Not a normal
|
|
53
|
+
# outcome — surface it rather than silently truncating the order.
|
|
54
|
+
raise ValueError("cycle detected in happens-before relation — inconsistent vector clocks")
|
|
55
|
+
chosen = ready[0]
|
|
56
|
+
ordered.append(events[chosen])
|
|
57
|
+
remaining.discard(chosen)
|
|
58
|
+
for j in successors[chosen]:
|
|
59
|
+
in_degree[j] -= 1
|
|
60
|
+
|
|
61
|
+
return ordered
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Ed25519 sign/verify (docs/LLD.md §6, docs/plan.md M4). Stdlib has no
|
|
2
|
+
modern asymmetric crypto, so this is the one new dependency: `cryptography`.
|
|
3
|
+
|
|
4
|
+
Trust model: the signature proves the platform's private key signed this
|
|
5
|
+
data — nothing more. A public key found *inside* a bundle is not trusted for
|
|
6
|
+
verification; the verifier must already hold the platform's public key from
|
|
7
|
+
an out-of-band channel (this is what makes offline, no-server verification
|
|
8
|
+
meaningful — see verify-cli).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidSignature
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_keypair() -> tuple[bytes, bytes]:
|
|
22
|
+
"""Returns (private_key_bytes, public_key_bytes), both raw 32-byte encodings."""
|
|
23
|
+
private_key = Ed25519PrivateKey.generate()
|
|
24
|
+
public_key = private_key.public_key()
|
|
25
|
+
priv_bytes = private_key.private_bytes(
|
|
26
|
+
encoding=serialization.Encoding.Raw,
|
|
27
|
+
format=serialization.PrivateFormat.Raw,
|
|
28
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
29
|
+
)
|
|
30
|
+
pub_bytes = public_key.public_bytes(
|
|
31
|
+
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
|
32
|
+
)
|
|
33
|
+
return priv_bytes, pub_bytes
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def public_key_from_private(private_key_bytes: bytes) -> bytes:
|
|
37
|
+
public_key = Ed25519PrivateKey.from_private_bytes(private_key_bytes).public_key()
|
|
38
|
+
return public_key.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sign(private_key_bytes: bytes, message: bytes) -> bytes:
|
|
42
|
+
return Ed25519PrivateKey.from_private_bytes(private_key_bytes).sign(message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify(public_key_bytes: bytes, message: bytes, signature: bytes) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
Ed25519PublicKey.from_public_bytes(public_key_bytes).verify(signature, message)
|
|
48
|
+
return True
|
|
49
|
+
except InvalidSignature:
|
|
50
|
+
return False
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Vector clocks for cross-agent causal ordering (docs/LLD.md §5, the moat).
|
|
2
|
+
|
|
3
|
+
Rules, maintained per agent:
|
|
4
|
+
1. Local event: vclock[self] += 1
|
|
5
|
+
2. On MSG_SEND: tick (rule 1), then attach the ticked clock to the message
|
|
6
|
+
3. On MSG_RECV: vclock = elementwise_max(vclock, msg.vclock), then vclock[self] += 1
|
|
7
|
+
|
|
8
|
+
Packed as canonical JSON (sorted-key), not a bespoke binary format — this is
|
|
9
|
+
Phase 2's first cut; a real varint packing is a measured optimization for
|
|
10
|
+
later, not a default (docs/plan.md §3.6's own reasoning applied here).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from pramana_core.hashing import canonical_json_bytes
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class VectorClock:
|
|
23
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def tick(self, agent_id: str) -> VectorClock:
|
|
26
|
+
return VectorClock({**self.counts, agent_id: self.counts.get(agent_id, 0) + 1})
|
|
27
|
+
|
|
28
|
+
def merge(self, other: VectorClock) -> VectorClock:
|
|
29
|
+
agents = set(self.counts) | set(other.counts)
|
|
30
|
+
return VectorClock({a: max(self.counts.get(a, 0), other.counts.get(a, 0)) for a in agents})
|
|
31
|
+
|
|
32
|
+
def happens_before(self, other: VectorClock) -> bool:
|
|
33
|
+
"""self -> other: every component of self <= other, and at least one strictly less."""
|
|
34
|
+
agents = set(self.counts) | set(other.counts)
|
|
35
|
+
le_all = all(self.counts.get(a, 0) <= other.counts.get(a, 0) for a in agents)
|
|
36
|
+
lt_some = any(self.counts.get(a, 0) < other.counts.get(a, 0) for a in agents)
|
|
37
|
+
return le_all and lt_some
|
|
38
|
+
|
|
39
|
+
def concurrent_with(self, other: VectorClock) -> bool:
|
|
40
|
+
return self != other and not self.happens_before(other) and not other.happens_before(self)
|
|
41
|
+
|
|
42
|
+
def to_bytes(self) -> bytes:
|
|
43
|
+
return canonical_json_bytes(self.counts)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_bytes(cls, data: bytes) -> VectorClock:
|
|
47
|
+
if not data:
|
|
48
|
+
return cls()
|
|
49
|
+
return cls(json.loads(data))
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Runs an existing `http.server.BaseHTTPRequestHandler` subclass under any
|
|
2
|
+
WSGI server (docs/plan.md §21.4), so gunicorn can host `pramana-api` and
|
|
3
|
+
`pramana-ingest` in production without `ThreadingHTTPServer`'s unbounded
|
|
4
|
+
thread-per-connection model, no request timeout, and no graceful shutdown.
|
|
5
|
+
|
|
6
|
+
Both services' routing — auth, RBAC, CORS, error handling — already lives on
|
|
7
|
+
their `Handler` classes and is covered by ~300 tests exercising it over real
|
|
8
|
+
HTTP. Re-deriving that logic a second time as plain WSGI functions would risk
|
|
9
|
+
the two implementations silently drifting apart the first time one of them
|
|
10
|
+
changes. Instead, this drives the *same* `Handler` class through a loopback,
|
|
11
|
+
in-memory "socket" — `socketserver.StreamRequestHandler` only needs a few
|
|
12
|
+
methods off whatever object it's given (`makefile` for reading, `sendall` for
|
|
13
|
+
writing), which is a much smaller surface than reimplementing routing.
|
|
14
|
+
|
|
15
|
+
The one property this must not break: a handler that checks Content-Length
|
|
16
|
+
*before* reading the body (ingest's whole size-cap exists to stop a
|
|
17
|
+
decompression bomb from being read into memory at all) must still get to run
|
|
18
|
+
that check before any of the body is pulled from the real WSGI input stream.
|
|
19
|
+
So the body is read lazily, on demand, exactly like a real socket would
|
|
20
|
+
deliver it — never buffered up front by this adapter.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import io
|
|
26
|
+
from collections.abc import Callable, Iterable
|
|
27
|
+
from http.server import BaseHTTPRequestHandler
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _LazyBodyReader(io.RawIOBase):
|
|
32
|
+
"""`.readline()` is served from the pre-built header block (small, fixed
|
|
33
|
+
size); once that's exhausted, `.read(n)` falls through to the real WSGI
|
|
34
|
+
body stream, pulling exactly the `n` bytes the handler asks for and no
|
|
35
|
+
more — the same shape a real socket read has.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, header_block: bytes, wsgi_input: Any):
|
|
39
|
+
self._buf = header_block
|
|
40
|
+
self._pos = 0
|
|
41
|
+
self._wsgi_input = wsgi_input
|
|
42
|
+
|
|
43
|
+
def readable(self) -> bool:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def readline(self, limit: int | None = -1) -> bytes:
|
|
47
|
+
if self._pos < len(self._buf):
|
|
48
|
+
end = self._buf.find(b"\n", self._pos)
|
|
49
|
+
end = len(self._buf) if end == -1 else end + 1
|
|
50
|
+
if limit is not None and limit >= 0:
|
|
51
|
+
end = min(end, self._pos + limit)
|
|
52
|
+
line = self._buf[self._pos : end]
|
|
53
|
+
self._pos = end
|
|
54
|
+
return line
|
|
55
|
+
return bytes(self._wsgi_input.readline() if limit is None or limit < 0 else self._wsgi_input.readline(limit))
|
|
56
|
+
|
|
57
|
+
def read(self, size: int = -1) -> bytes:
|
|
58
|
+
if self._pos < len(self._buf):
|
|
59
|
+
remaining = self._buf[self._pos :]
|
|
60
|
+
self._pos = len(self._buf)
|
|
61
|
+
if size < 0:
|
|
62
|
+
return bytes(remaining) + bytes(self._wsgi_input.read())
|
|
63
|
+
if len(remaining) >= size:
|
|
64
|
+
# Only reachable if a caller reads less than a full line at a
|
|
65
|
+
# time during header parsing, which http.client doesn't do —
|
|
66
|
+
# kept correct anyway rather than assumed unreachable.
|
|
67
|
+
leftover, consumed = remaining[size:], remaining[:size]
|
|
68
|
+
self._buf = leftover + self._buf[len(self._buf) :]
|
|
69
|
+
self._pos = 0
|
|
70
|
+
return consumed
|
|
71
|
+
return bytes(remaining) + bytes(self._wsgi_input.read(size - len(remaining)))
|
|
72
|
+
return bytes(self._wsgi_input.read() if size < 0 else self._wsgi_input.read(size))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _FakeConnection:
|
|
76
|
+
"""Enough of a socket for `socketserver.StreamRequestHandler.setup()`:
|
|
77
|
+
`.makefile()` for the read side. The write side never calls `.makefile`
|
|
78
|
+
at all — `BaseHTTPRequestHandler`'s default `wbufsize = 0` makes
|
|
79
|
+
`StreamRequestHandler` wrap this object directly in a `_SocketWriter`,
|
|
80
|
+
which calls `.sendall()` — so that's the only write method needed here.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, header_block: bytes, wsgi_input: Any):
|
|
84
|
+
self._reader = _LazyBodyReader(header_block, wsgi_input)
|
|
85
|
+
self.response = bytearray()
|
|
86
|
+
|
|
87
|
+
def makefile(self, mode: str, *args: Any, **kwargs: Any) -> Any:
|
|
88
|
+
if "r" not in mode:
|
|
89
|
+
raise AssertionError(f"unexpected makefile mode {mode!r} — see module docstring")
|
|
90
|
+
return self._reader
|
|
91
|
+
|
|
92
|
+
def sendall(self, data: bytes) -> None:
|
|
93
|
+
self.response.extend(data)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _environ_to_header_block(environ: dict[str, Any]) -> bytes:
|
|
97
|
+
method = environ["REQUEST_METHOD"]
|
|
98
|
+
path = environ.get("PATH_INFO", "") or "/"
|
|
99
|
+
qs = environ.get("QUERY_STRING", "")
|
|
100
|
+
target = f"{path}?{qs}" if qs else path
|
|
101
|
+
lines = [f"{method} {target} HTTP/1.1"]
|
|
102
|
+
for key, value in environ.items():
|
|
103
|
+
if key.startswith("HTTP_"):
|
|
104
|
+
header = key[len("HTTP_") :].replace("_", "-").title()
|
|
105
|
+
lines.append(f"{header}: {value}")
|
|
106
|
+
if environ.get("CONTENT_TYPE"):
|
|
107
|
+
lines.append(f"Content-Type: {environ['CONTENT_TYPE']}")
|
|
108
|
+
if environ.get("CONTENT_LENGTH"):
|
|
109
|
+
lines.append(f"Content-Length: {environ['CONTENT_LENGTH']}")
|
|
110
|
+
# Exactly one request per WSGI call — nothing here models keep-alive, and
|
|
111
|
+
# this line is what makes handle()'s request loop stop after the first.
|
|
112
|
+
lines.append("Connection: close")
|
|
113
|
+
return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def make_wsgi_app(
|
|
117
|
+
handler_cls: type[BaseHTTPRequestHandler], server: Any
|
|
118
|
+
) -> Callable[[dict[str, Any], Callable[..., Any]], Iterable[bytes]]:
|
|
119
|
+
"""`handler_cls` is a `BaseHTTPRequestHandler` subclass, e.g. one of this
|
|
120
|
+
project's `make_handler(...)` results. `server` only needs to satisfy
|
|
121
|
+
whatever the handler class actually reads off `self.server` — neither
|
|
122
|
+
service's routes do, today.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def app(environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]:
|
|
126
|
+
header_block = _environ_to_header_block(environ)
|
|
127
|
+
conn = _FakeConnection(header_block, environ["wsgi.input"])
|
|
128
|
+
client_address = (environ.get("REMOTE_ADDR", ""), 0)
|
|
129
|
+
# `conn` duck-types the small slice of `socket.socket` that
|
|
130
|
+
# StreamRequestHandler.setup() actually calls (see class docstring) —
|
|
131
|
+
# it is not one, hence the cast-by-Any rather than a real socket.
|
|
132
|
+
handler_cls(conn, client_address, server) # type: ignore[arg-type]
|
|
133
|
+
|
|
134
|
+
status_line, _, rest = bytes(conn.response).partition(b"\r\n")
|
|
135
|
+
raw_headers, _, body = rest.partition(b"\r\n\r\n")
|
|
136
|
+
status = status_line.decode("latin-1").split(" ", 1)[1] # "HTTP/1.1 200 OK" -> "200 OK"
|
|
137
|
+
headers = []
|
|
138
|
+
for line in raw_headers.split(b"\r\n"):
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
name, _, value = line.partition(b":")
|
|
142
|
+
headers.append((name.decode("latin-1").strip(), value.decode("latin-1").strip()))
|
|
143
|
+
start_response(status, headers)
|
|
144
|
+
return [body]
|
|
145
|
+
|
|
146
|
+
return app
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pramana_core.chain import compute_this_hash
|
|
2
|
+
from pramana_proto.v1.event_pb2 import LLM_CALL, Event
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_event(**overrides) -> Event:
|
|
6
|
+
defaults = {
|
|
7
|
+
"event_id": "e1",
|
|
8
|
+
"tenant_id": "t1",
|
|
9
|
+
"trace_id": "run-1",
|
|
10
|
+
"agent_id": "agent-1",
|
|
11
|
+
"call_site_id": "cs1",
|
|
12
|
+
"logical_seq": 1,
|
|
13
|
+
"kind": LLM_CALL,
|
|
14
|
+
"input_hash": "ih1",
|
|
15
|
+
"payload_ref": "pr1",
|
|
16
|
+
"ts_wall_ns": 123,
|
|
17
|
+
"call_site_ordinal": 0,
|
|
18
|
+
}
|
|
19
|
+
defaults.update(overrides)
|
|
20
|
+
return Event(**defaults)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_same_event_and_prev_hash_hash_equal():
|
|
24
|
+
e = make_event()
|
|
25
|
+
assert compute_this_hash(e, "prev-abc") == compute_this_hash(e, "prev-abc")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_different_prev_hash_changes_this_hash():
|
|
29
|
+
e = make_event()
|
|
30
|
+
assert compute_this_hash(e, "prev-a") != compute_this_hash(e, "prev-b")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_this_hash_field_on_event_is_ignored_as_input():
|
|
34
|
+
e1 = make_event()
|
|
35
|
+
e2 = make_event(this_hash="whatever-junk")
|
|
36
|
+
assert compute_this_hash(e1, "p") == compute_this_hash(e2, "p")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_tampering_any_field_changes_hash():
|
|
40
|
+
base = compute_this_hash(make_event(), "p")
|
|
41
|
+
tampered = compute_this_hash(make_event(input_hash="different"), "p")
|
|
42
|
+
assert base != tampered
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pramana_core.hashing import NonFiniteValueError, canonical_json_bytes, sha256_hex
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_key_order_does_not_affect_bytes():
|
|
8
|
+
a = canonical_json_bytes({"b": 1, "a": 2})
|
|
9
|
+
b = canonical_json_bytes({"a": 2, "b": 1})
|
|
10
|
+
assert a == b
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_unicode_is_preserved_not_escaped():
|
|
14
|
+
out = canonical_json_bytes({"name": "café"})
|
|
15
|
+
assert "café".encode() in out
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_rejects_nan():
|
|
19
|
+
with pytest.raises(NonFiniteValueError):
|
|
20
|
+
canonical_json_bytes({"x": float("nan")})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_rejects_infinity_nested():
|
|
24
|
+
with pytest.raises(NonFiniteValueError):
|
|
25
|
+
canonical_json_bytes({"x": [1, {"y": math.inf}]})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_sha256_hex_is_deterministic():
|
|
29
|
+
data = canonical_json_bytes({"a": 1})
|
|
30
|
+
assert sha256_hex(data) == sha256_hex(data)
|
|
31
|
+
assert len(sha256_hex(data)) == 64
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from pramana_core.ids import uuid7
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_uuid7_is_valid_uuid_version_7():
|
|
8
|
+
u = uuid.UUID(uuid7())
|
|
9
|
+
assert u.version == 7
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_uuid7_is_time_sortable():
|
|
13
|
+
# one per millisecond — uuid7 only orders at millisecond granularity;
|
|
14
|
+
# ties within the same millisecond break on random bits, not time.
|
|
15
|
+
ids = []
|
|
16
|
+
for _ in range(5):
|
|
17
|
+
ids.append(uuid7())
|
|
18
|
+
time.sleep(0.002)
|
|
19
|
+
assert ids == sorted(ids)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from pramana_core.merkle import merkle_root
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def h(s: str) -> str:
|
|
7
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_empty_is_deterministic():
|
|
11
|
+
assert merkle_root([]) == merkle_root([])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_single_leaf():
|
|
15
|
+
root = merkle_root([h("a")])
|
|
16
|
+
assert len(root) == 64
|
|
17
|
+
assert root != h("a") # leaf hash is domain-separated, not the raw hash
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_order_matters():
|
|
21
|
+
root1 = merkle_root([h("a"), h("b")])
|
|
22
|
+
root2 = merkle_root([h("b"), h("a")])
|
|
23
|
+
assert root1 != root2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_deterministic_across_calls():
|
|
27
|
+
leaves = [h("a"), h("b"), h("c"), h("d"), h("e")]
|
|
28
|
+
assert merkle_root(leaves) == merkle_root(leaves)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_changing_one_leaf_changes_root():
|
|
32
|
+
leaves = [h("a"), h("b"), h("c")]
|
|
33
|
+
tampered = [h("a"), h("TAMPERED"), h("c")]
|
|
34
|
+
assert merkle_root(leaves) != merkle_root(tampered)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_non_power_of_two_leaf_counts():
|
|
38
|
+
# RFC 6962 shape handles any n, not just powers of 2.
|
|
39
|
+
for n in range(1, 12):
|
|
40
|
+
leaves = [h(str(i)) for i in range(n)]
|
|
41
|
+
root = merkle_root(leaves)
|
|
42
|
+
assert len(root) == 64
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""docs/plan.md §12 item 4."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pramana_core import metrics
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_counter_starts_at_zero_and_increments():
|
|
9
|
+
c = metrics.counter(f"test_counter_{id(object())}", "a test counter")
|
|
10
|
+
assert c.value == 0
|
|
11
|
+
c.inc()
|
|
12
|
+
c.inc(4)
|
|
13
|
+
assert c.value == 5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_counter_is_a_singleton_per_name():
|
|
17
|
+
name = f"test_singleton_{id(object())}"
|
|
18
|
+
a = metrics.counter(name, "help a")
|
|
19
|
+
b = metrics.counter(name, "help b") # help text ignored on re-registration
|
|
20
|
+
a.inc(3)
|
|
21
|
+
assert b.value == 3
|
|
22
|
+
assert a is b
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_render_text_includes_help_type_and_value():
|
|
26
|
+
name = f"test_render_{id(object())}"
|
|
27
|
+
c = metrics.counter(name, "renders correctly")
|
|
28
|
+
c.inc(7)
|
|
29
|
+
text = metrics.render_text().decode()
|
|
30
|
+
assert f"# HELP {name} renders correctly" in text
|
|
31
|
+
assert f"# TYPE {name} counter" in text
|
|
32
|
+
assert f"{name} 7" in text
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pramana_core.sequencer import total_order
|
|
3
|
+
from pramana_core.vclock import VectorClock
|
|
4
|
+
from pramana_proto.v1.event_pb2 import LLM_CALL, MSG_RECV, MSG_SEND, Event
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_event(agent_id, seq, vclock: VectorClock, trace_id="t", **overrides):
|
|
8
|
+
defaults = {
|
|
9
|
+
"event_id": f"{agent_id}-{seq}",
|
|
10
|
+
"tenant_id": "t1",
|
|
11
|
+
"trace_id": trace_id,
|
|
12
|
+
"agent_id": agent_id,
|
|
13
|
+
"call_site_id": "cs1",
|
|
14
|
+
"call_site_ordinal": seq,
|
|
15
|
+
"logical_seq": seq,
|
|
16
|
+
"kind": LLM_CALL,
|
|
17
|
+
"input_hash": "ih",
|
|
18
|
+
"payload_ref": "pr",
|
|
19
|
+
"ts_wall_ns": seq,
|
|
20
|
+
"vclock": vclock.to_bytes(),
|
|
21
|
+
}
|
|
22
|
+
defaults.update(overrides)
|
|
23
|
+
return Event(**defaults)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_single_agent_local_events_stay_in_program_order():
|
|
27
|
+
vc = VectorClock()
|
|
28
|
+
events = []
|
|
29
|
+
for i in range(5):
|
|
30
|
+
vc = vc.tick("a")
|
|
31
|
+
events.append(make_event("a", i, vc))
|
|
32
|
+
|
|
33
|
+
# shuffle input order — total_order must still recover program order
|
|
34
|
+
ordered = total_order([events[3], events[0], events[4], events[1], events[2]])
|
|
35
|
+
assert [e.event_id for e in ordered] == [e.event_id for e in events]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_message_send_before_receive_lld_worked_example():
|
|
39
|
+
# A:[1,0,0] --handoff--> B:[1,1,0] --tool--> C:[1,1,1] --result--> A:[2,1,1]
|
|
40
|
+
vc_a1 = VectorClock().tick("A")
|
|
41
|
+
send = make_event("A", 0, vc_a1, kind=MSG_SEND)
|
|
42
|
+
|
|
43
|
+
vc_b1 = VectorClock().merge(vc_a1).tick("B")
|
|
44
|
+
recv_b = make_event("B", 0, vc_b1, kind=MSG_RECV)
|
|
45
|
+
|
|
46
|
+
vc_c1 = VectorClock().merge(vc_b1).tick("C")
|
|
47
|
+
recv_c = make_event("C", 0, vc_c1, kind=MSG_RECV)
|
|
48
|
+
|
|
49
|
+
vc_a2 = vc_a1.merge(vc_c1).tick("A")
|
|
50
|
+
result_a = make_event("A", 1, vc_a2, kind=MSG_RECV)
|
|
51
|
+
|
|
52
|
+
ordered = total_order([result_a, recv_c, send, recv_b])
|
|
53
|
+
assert [e.event_id for e in ordered] == ["A-0", "B-0", "C-0", "A-1"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_concurrent_events_break_ties_by_trace_agent_seq():
|
|
57
|
+
vc_a = VectorClock({"a": 5}) # unrelated components — neither happens-before the other
|
|
58
|
+
vc_b = VectorClock({"b": 5})
|
|
59
|
+
event_a = make_event("a", 0, vc_a)
|
|
60
|
+
event_b = make_event("b", 0, vc_b)
|
|
61
|
+
|
|
62
|
+
ordered = total_order([event_b, event_a])
|
|
63
|
+
assert [e.event_id for e in ordered] == ["a-0", "b-0"] # "a" < "b" lexicographically
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_two_independent_agents_interleave_by_tie_break_when_concurrent():
|
|
67
|
+
# No messages exchanged — every event is concurrent with every event of
|
|
68
|
+
# the other agent. Order must still be fully deterministic.
|
|
69
|
+
events_a = []
|
|
70
|
+
vc = VectorClock()
|
|
71
|
+
for i in range(3):
|
|
72
|
+
vc = vc.tick("agent_a")
|
|
73
|
+
events_a.append(make_event("agent_a", i, vc))
|
|
74
|
+
|
|
75
|
+
events_b = []
|
|
76
|
+
vc = VectorClock()
|
|
77
|
+
for i in range(3):
|
|
78
|
+
vc = vc.tick("agent_b")
|
|
79
|
+
events_b.append(make_event("agent_b", i, vc))
|
|
80
|
+
|
|
81
|
+
ordered = total_order(events_a + events_b)
|
|
82
|
+
# agent_a's events sort before agent_b's (tie-break on agent_id), each
|
|
83
|
+
# internally in program order (real causal edges within the agent).
|
|
84
|
+
assert [e.event_id for e in ordered] == [
|
|
85
|
+
"agent_a-0", "agent_a-1", "agent_a-2", "agent_b-0", "agent_b-1", "agent_b-2",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_result_is_deterministic_regardless_of_input_order():
|
|
90
|
+
vc_a1 = VectorClock().tick("A")
|
|
91
|
+
send = make_event("A", 0, vc_a1, kind=MSG_SEND)
|
|
92
|
+
vc_b1 = VectorClock().merge(vc_a1).tick("B")
|
|
93
|
+
recv = make_event("B", 0, vc_b1, kind=MSG_RECV)
|
|
94
|
+
|
|
95
|
+
order1 = [e.event_id for e in total_order([send, recv])]
|
|
96
|
+
order2 = [e.event_id for e in total_order([recv, send])]
|
|
97
|
+
assert order1 == order2 == ["A-0", "B-0"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_cycle_is_detected_not_silently_resolved():
|
|
101
|
+
# Two events that (incorrectly) each claim to happen-before the other —
|
|
102
|
+
# not producible by correctly-maintained vclocks, but the algorithm must
|
|
103
|
+
# not silently drop or misorder events if it ever sees corrupted data.
|
|
104
|
+
class FakeClock:
|
|
105
|
+
def __init__(self, other):
|
|
106
|
+
self.other = other
|
|
107
|
+
|
|
108
|
+
def happens_before(self, o):
|
|
109
|
+
return o is self.other
|
|
110
|
+
|
|
111
|
+
a = make_event("a", 0, VectorClock({"a": 1}))
|
|
112
|
+
b = make_event("b", 0, VectorClock({"b": 1}))
|
|
113
|
+
|
|
114
|
+
import pramana_core.sequencer as sequencer_module
|
|
115
|
+
|
|
116
|
+
original = sequencer_module.VectorClock.from_bytes
|
|
117
|
+
fake_a, fake_b = FakeClock(None), FakeClock(None)
|
|
118
|
+
fake_a.other, fake_b.other = fake_b, fake_a
|
|
119
|
+
|
|
120
|
+
def fake_from_bytes(data):
|
|
121
|
+
return fake_a if data == a.vclock else fake_b
|
|
122
|
+
|
|
123
|
+
sequencer_module.VectorClock.from_bytes = staticmethod(fake_from_bytes)
|
|
124
|
+
try:
|
|
125
|
+
with pytest.raises(ValueError):
|
|
126
|
+
total_order([a, b])
|
|
127
|
+
finally:
|
|
128
|
+
sequencer_module.VectorClock.from_bytes = original
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pramana_core.signing import generate_keypair, sign, verify
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_valid_signature_verifies():
|
|
5
|
+
priv, pub = generate_keypair()
|
|
6
|
+
sig = sign(priv, b"hello")
|
|
7
|
+
assert verify(pub, b"hello", sig) is True
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_tampered_message_fails():
|
|
11
|
+
priv, pub = generate_keypair()
|
|
12
|
+
sig = sign(priv, b"hello")
|
|
13
|
+
assert verify(pub, b"goodbye", sig) is False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_wrong_public_key_fails():
|
|
17
|
+
priv, _ = generate_keypair()
|
|
18
|
+
_, other_pub = generate_keypair()
|
|
19
|
+
sig = sign(priv, b"hello")
|
|
20
|
+
assert verify(other_pub, b"hello", sig) is False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_keys_are_32_bytes_raw():
|
|
24
|
+
priv, pub = generate_keypair()
|
|
25
|
+
assert len(priv) == 32
|
|
26
|
+
assert len(pub) == 32
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from pramana_core.vclock import VectorClock
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_tick_increments_own_component():
|
|
5
|
+
vc = VectorClock().tick("a")
|
|
6
|
+
assert vc.counts == {"a": 1}
|
|
7
|
+
vc = vc.tick("a")
|
|
8
|
+
assert vc.counts == {"a": 2}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_tick_does_not_mutate_original():
|
|
12
|
+
vc = VectorClock({"a": 1})
|
|
13
|
+
vc2 = vc.tick("a")
|
|
14
|
+
assert vc.counts == {"a": 1}
|
|
15
|
+
assert vc2.counts == {"a": 2}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_merge_takes_elementwise_max():
|
|
19
|
+
a = VectorClock({"a": 3, "b": 1})
|
|
20
|
+
b = VectorClock({"a": 1, "b": 5, "c": 2})
|
|
21
|
+
merged = a.merge(b)
|
|
22
|
+
assert merged.counts == {"a": 3, "b": 5, "c": 2}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_happens_before_true_case():
|
|
26
|
+
a = VectorClock({"a": 1})
|
|
27
|
+
b = a.tick("a").merge(VectorClock({"b": 1})).tick("b")
|
|
28
|
+
assert a.happens_before(b)
|
|
29
|
+
assert not b.happens_before(a)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_concurrent_events_are_neither_before_nor_after():
|
|
33
|
+
a = VectorClock({"a": 1, "b": 0})
|
|
34
|
+
b = VectorClock({"a": 0, "b": 1})
|
|
35
|
+
assert a.concurrent_with(b)
|
|
36
|
+
assert not a.happens_before(b)
|
|
37
|
+
assert not b.happens_before(a)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_identical_clocks_are_not_happens_before_or_concurrent():
|
|
41
|
+
a = VectorClock({"a": 1})
|
|
42
|
+
b = VectorClock({"a": 1})
|
|
43
|
+
assert not a.happens_before(b)
|
|
44
|
+
assert not a.concurrent_with(b)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_pack_unpack_round_trip():
|
|
48
|
+
vc = VectorClock({"agent_a": 3, "agent_b": 7})
|
|
49
|
+
assert VectorClock.from_bytes(vc.to_bytes()) == vc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_empty_bytes_unpacks_to_empty_clock():
|
|
53
|
+
assert VectorClock.from_bytes(b"") == VectorClock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_message_send_receive_scenario_from_lld():
|
|
57
|
+
# A:[1,0,0] --handoff--> B:[1,1,0] --tool--> C:[1,1,1] --result--> A:[2,1,1]
|
|
58
|
+
# docs/LLD.md §5 worked example.
|
|
59
|
+
a = VectorClock().tick("A")
|
|
60
|
+
assert a.counts == {"A": 1}
|
|
61
|
+
|
|
62
|
+
b = VectorClock().merge(a).tick("B")
|
|
63
|
+
assert b.counts == {"A": 1, "B": 1}
|
|
64
|
+
|
|
65
|
+
c = VectorClock().merge(b).tick("C")
|
|
66
|
+
assert c.counts == {"A": 1, "B": 1, "C": 1}
|
|
67
|
+
|
|
68
|
+
a2 = a.merge(c).tick("A")
|
|
69
|
+
assert a2.counts == {"A": 2, "B": 1, "C": 1}
|
|
70
|
+
|
|
71
|
+
assert a.happens_before(b)
|
|
72
|
+
assert b.happens_before(c)
|
|
73
|
+
assert a.happens_before(a2)
|
|
74
|
+
assert c.happens_before(a2)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from http.server import BaseHTTPRequestHandler
|
|
3
|
+
|
|
4
|
+
from pramana_core.wsgi import make_wsgi_app
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _environ(method="GET", path="/", body=b"", headers=None, query=""):
|
|
8
|
+
env = {
|
|
9
|
+
"REQUEST_METHOD": method,
|
|
10
|
+
"PATH_INFO": path,
|
|
11
|
+
"QUERY_STRING": query,
|
|
12
|
+
"wsgi.input": io.BytesIO(body),
|
|
13
|
+
"CONTENT_LENGTH": str(len(body)) if body else "",
|
|
14
|
+
}
|
|
15
|
+
for k, v in (headers or {}).items():
|
|
16
|
+
env[f"HTTP_{k.upper().replace('-', '_')}"] = v
|
|
17
|
+
return env
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _EchoHandler(BaseHTTPRequestHandler):
|
|
21
|
+
def log_message(self, fmt, *args):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def do_GET(self):
|
|
25
|
+
if self.path.startswith("/headers"):
|
|
26
|
+
got = self.headers.get("X-Probe", "")
|
|
27
|
+
body = got.encode()
|
|
28
|
+
else:
|
|
29
|
+
body = b"hello"
|
|
30
|
+
self.send_response(200)
|
|
31
|
+
self.send_header("Content-Type", "text/plain")
|
|
32
|
+
self.send_header("Content-Length", str(len(body)))
|
|
33
|
+
self.end_headers()
|
|
34
|
+
self.wfile.write(body)
|
|
35
|
+
|
|
36
|
+
def do_POST(self):
|
|
37
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
38
|
+
body = self.rfile.read(length)
|
|
39
|
+
self.send_response(201)
|
|
40
|
+
self.send_header("Content-Length", str(len(body)))
|
|
41
|
+
self.end_headers()
|
|
42
|
+
self.wfile.write(body)
|
|
43
|
+
|
|
44
|
+
def do_DELETE(self):
|
|
45
|
+
self.send_response(404)
|
|
46
|
+
self.send_header("Content-Length", "0")
|
|
47
|
+
self.end_headers()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _call(app, environ):
|
|
51
|
+
captured = {}
|
|
52
|
+
|
|
53
|
+
def start_response(status, headers):
|
|
54
|
+
captured["status"] = status
|
|
55
|
+
captured["headers"] = dict(headers)
|
|
56
|
+
|
|
57
|
+
body = b"".join(app(environ, start_response))
|
|
58
|
+
return captured["status"], captured["headers"], body
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_get_round_trips_status_headers_and_body():
|
|
62
|
+
app = make_wsgi_app(_EchoHandler, server=None)
|
|
63
|
+
status, headers, body = _call(app, _environ("GET", "/"))
|
|
64
|
+
assert status == "200 OK"
|
|
65
|
+
assert headers["Content-Type"] == "text/plain"
|
|
66
|
+
assert body == b"hello"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_request_headers_reach_the_handler():
|
|
70
|
+
app = make_wsgi_app(_EchoHandler, server=None)
|
|
71
|
+
_, _, body = _call(app, _environ("GET", "/headers", headers={"X-Probe": "hi-there"}))
|
|
72
|
+
assert body == b"hi-there"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_post_body_round_trips():
|
|
76
|
+
app = make_wsgi_app(_EchoHandler, server=None)
|
|
77
|
+
status, _, body = _call(app, _environ("POST", "/v1/whatever", body=b"payload-bytes"))
|
|
78
|
+
assert status == "201 Created"
|
|
79
|
+
assert body == b"payload-bytes"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_query_string_reaches_the_handler():
|
|
83
|
+
class _QueryHandler(BaseHTTPRequestHandler):
|
|
84
|
+
def log_message(self, fmt, *args):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def do_GET(self):
|
|
88
|
+
body = self.path.encode()
|
|
89
|
+
self.send_response(200)
|
|
90
|
+
self.send_header("Content-Length", str(len(body)))
|
|
91
|
+
self.end_headers()
|
|
92
|
+
self.wfile.write(body)
|
|
93
|
+
|
|
94
|
+
app = make_wsgi_app(_QueryHandler, server=None)
|
|
95
|
+
_, _, body = _call(app, _environ("GET", "/v1/traces", query="limit=2&before=100"))
|
|
96
|
+
assert body == b"/v1/traces?limit=2&before=100"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_route_that_never_reads_the_body_never_touches_wsgi_input():
|
|
100
|
+
"""The property the whole adapter exists to preserve: ingest's
|
|
101
|
+
Content-Length cap runs before the body is read, so a route that 413s
|
|
102
|
+
before calling self.rfile.read(...) must not have pulled any body bytes
|
|
103
|
+
out of the real WSGI input stream at all.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
class _NeverReadsBody(BaseHTTPRequestHandler):
|
|
107
|
+
def log_message(self, fmt, *args):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def do_POST(self):
|
|
111
|
+
self.send_response(413)
|
|
112
|
+
self.send_header("Content-Length", "0")
|
|
113
|
+
self.end_headers()
|
|
114
|
+
|
|
115
|
+
class _TrackedInput(io.BytesIO):
|
|
116
|
+
def __init__(self, data):
|
|
117
|
+
super().__init__(data)
|
|
118
|
+
self.read_calls = 0
|
|
119
|
+
|
|
120
|
+
def read(self, size=-1):
|
|
121
|
+
self.read_calls += 1
|
|
122
|
+
return super().read(size)
|
|
123
|
+
|
|
124
|
+
tracked = _TrackedInput(b"x" * 1000)
|
|
125
|
+
environ = _environ("POST", "/v1/events:batch", body=b"")
|
|
126
|
+
environ["wsgi.input"] = tracked
|
|
127
|
+
environ["CONTENT_LENGTH"] = "1000"
|
|
128
|
+
|
|
129
|
+
app = make_wsgi_app(_NeverReadsBody, server=None)
|
|
130
|
+
status, _, _ = _call(app, environ)
|
|
131
|
+
|
|
132
|
+
assert status.startswith("413 ") # exact reason phrase changed between Python versions
|
|
133
|
+
assert tracked.read_calls == 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_delete_status_with_no_message_still_parses():
|
|
137
|
+
app = make_wsgi_app(_EchoHandler, server=None)
|
|
138
|
+
status, _, _ = _call(app, _environ("DELETE", "/x"))
|
|
139
|
+
assert status == "404 Not Found"
|