R2: run manifest / provenance writer for reproducible, auditable runs
write_manifest() freezes at circuit-experiment start everything needed to
reproduce and audit a SOR run and writes an immutable
output/sor-runs/<ts>/manifest.json: the R1 --sor-seed, topology/selector/churn
schedule id, one persona fingerprint per participating node, worktree git SHA,
isolated engine kind + image digest, pip/cargo dependency freeze, and
start/stop timestamps. The events.jsonl SHA-256 field is reserved for R3.
Provenance only: no forwarder, no engine spawned. Defense-in-depth to keep
containment load-bearing, the schema refuses to record a non-isolated
("local") engine at run or node level; the binding assertion still lands with
the R4 forwarder.
- cmd_chat/sor/provenance.py: Node/RunManifest, node_fingerprint (bit-for-bit
mirror of persona.rs::fingerprint_of), git/deps capture, explicit
dependency-free schema validator.
- hh/src/persona.rs: fingerprint_of known-vector parity test.
- tests: R2 acceptance predicate (manifest present + schema-valid, non-empty
seed, >=1 fingerprint/node), immutability, containment rejection of local
engines, and cross-language fingerprint parity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""R2 — Run manifest / provenance writer for the SOR measurement instrument.
|
||||
|
||||
`write_manifest()` freezes, at circuit-experiment start, everything needed to
|
||||
reproduce and audit a run: the R1 `--sor-seed`, the topology / selector / churn
|
||||
schedule id, one persona fingerprint per participating node (mirrors
|
||||
`hh/src/persona.rs::fingerprint_of`), the worktree git SHA, the isolated engine
|
||||
kind + image digest, a pip/cargo dependency freeze, and start/stop timestamps.
|
||||
It writes an immutable `output/sor-runs/<ts>/manifest.json`.
|
||||
|
||||
Provenance only — this module forwards no traffic and spawns no engine. As
|
||||
defense-in-depth it refuses to record a non-isolated (`local`) engine, but the
|
||||
load-bearing containment assertion lives with the R4 forwarder. The event-log
|
||||
SHA-256 field is reserved here and filled by R3 when the log is closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
SCHEMA_VERSION = "sor-manifest/1"
|
||||
|
||||
# Engines that satisfy the containment law (isolated-engine-only). "local" is
|
||||
# never a valid engine for a SOR run — recording it is refused.
|
||||
ISOLATED_ENGINES = ("docker", "multipass")
|
||||
|
||||
|
||||
def node_fingerprint(pub_b64: str) -> str:
|
||||
"""Short fingerprint of a base64 Ed25519 pubkey: sha256(raw)[:4] hex.
|
||||
|
||||
Bit-for-bit mirror of hh/src/persona.rs::fingerprint_of. Raises ValueError
|
||||
on undecodable input (a node with no valid persona cannot be recorded)."""
|
||||
try:
|
||||
raw = base64.b64decode(pub_b64, validate=True)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to ValueError
|
||||
raise ValueError(f"invalid persona pubkey: {exc}") from exc
|
||||
return hashlib.sha256(raw).digest()[:4].hex()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
"""A participating node in a SOR circuit run."""
|
||||
|
||||
role: str # "host" | "hop" | "bridge"
|
||||
persona_pub_b64: str
|
||||
engine: str = "docker"
|
||||
image_digest: Optional[str] = None
|
||||
|
||||
def to_entry(self) -> Dict[str, Any]:
|
||||
if self.engine not in ISOLATED_ENGINES:
|
||||
raise ValueError(
|
||||
f"containment: node engine {self.engine!r} is not isolated "
|
||||
f"(allowed: {ISOLATED_ENGINES})"
|
||||
)
|
||||
return {
|
||||
"role": self.role,
|
||||
"persona_pub_b64": self.persona_pub_b64,
|
||||
"fingerprint": node_fingerprint(self.persona_pub_b64),
|
||||
"engine": self.engine,
|
||||
"image_digest": self.image_digest,
|
||||
}
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def capture_git(worktree_root: Path) -> Dict[str, Any]:
|
||||
"""Best-effort worktree git provenance. Never raises; records nulls if the
|
||||
worktree is not a git repo (a manifest must still be schema-valid)."""
|
||||
|
||||
def _run(args: List[str]) -> Optional[str]:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(worktree_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return out.stdout.strip() if out.returncode == 0 else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
sha = _run(["rev-parse", "HEAD"])
|
||||
branch = _run(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
status = _run(["status", "--porcelain"])
|
||||
return {
|
||||
"worktree_sha": sha,
|
||||
"branch": branch,
|
||||
"dirty": bool(status) if status is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def capture_deps(worktree_root: Path) -> Dict[str, Any]:
|
||||
"""Freeze dependency provenance: sha256 of the pip freeze list and of the
|
||||
Cargo.lock (if present). Records the freeze list itself for auditability."""
|
||||
try:
|
||||
freeze = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "freeze"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
).stdout
|
||||
except Exception: # noqa: BLE001
|
||||
freeze = ""
|
||||
pip_lines = sorted(l for l in freeze.splitlines() if l.strip())
|
||||
pip_blob = "\n".join(pip_lines).encode()
|
||||
|
||||
cargo_lock = worktree_root / "hh" / "Cargo.lock"
|
||||
cargo_sha: Optional[str] = None
|
||||
if cargo_lock.is_file():
|
||||
cargo_sha = hashlib.sha256(cargo_lock.read_bytes()).hexdigest()
|
||||
|
||||
return {
|
||||
"pip_freeze_sha256": hashlib.sha256(pip_blob).hexdigest(),
|
||||
"pip_freeze": pip_lines,
|
||||
"cargo_lock_sha256": cargo_sha,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunManifest:
|
||||
"""In-memory manifest; `write()` renders it immutably to disk."""
|
||||
|
||||
run_id: str
|
||||
sor_seed: int
|
||||
topology: str
|
||||
selector: str
|
||||
churn_schedule_id: str
|
||||
nodes: List[Node]
|
||||
engine_kind: str = "docker"
|
||||
engine_image_digest: Optional[str] = None
|
||||
worktree_root: Path = field(default_factory=lambda: Path.cwd())
|
||||
start_utc: Optional[str] = None
|
||||
stop_utc: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
if self.engine_kind not in ISOLATED_ENGINES:
|
||||
raise ValueError(
|
||||
f"containment: run engine {self.engine_kind!r} is not isolated "
|
||||
f"(allowed: {ISOLATED_ENGINES})"
|
||||
)
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": self.run_id,
|
||||
"created_utc": _utc_now_iso(),
|
||||
"sor_seed": self.sor_seed,
|
||||
"topology": self.topology,
|
||||
"selector": self.selector,
|
||||
"churn_schedule_id": self.churn_schedule_id,
|
||||
"nodes": [n.to_entry() for n in self.nodes],
|
||||
"engine": {
|
||||
"kind": self.engine_kind,
|
||||
"image_digest": self.engine_image_digest,
|
||||
},
|
||||
"git": capture_git(self.worktree_root),
|
||||
"deps": capture_deps(self.worktree_root),
|
||||
"timestamps": {
|
||||
"start_utc": self.start_utc or _utc_now_iso(),
|
||||
"stop_utc": self.stop_utc,
|
||||
},
|
||||
# Reserved for R3: sha256 of the closed events.jsonl.
|
||||
"events": {"path": "events.jsonl", "sha256": None},
|
||||
}
|
||||
|
||||
|
||||
def write_manifest(run_dir: Path, manifest: RunManifest) -> Dict[str, Any]:
|
||||
"""Render `manifest` to `<run_dir>/manifest.json` and return the dict.
|
||||
|
||||
Refuses to overwrite an existing manifest (artifacts are immutable). The
|
||||
written document is validated before return; an invalid manifest raises and
|
||||
leaves nothing partial on disk."""
|
||||
run_dir = Path(run_dir)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = run_dir / "manifest.json"
|
||||
if path.exists():
|
||||
raise FileExistsError(f"manifest already exists (immutable): {path}")
|
||||
|
||||
doc = manifest.to_dict()
|
||||
validate_manifest(doc)
|
||||
path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n")
|
||||
return doc
|
||||
|
||||
|
||||
# --- schema -----------------------------------------------------------------
|
||||
|
||||
def validate_manifest(doc: Dict[str, Any]) -> None:
|
||||
"""Explicit, dependency-free schema check. Raises ValueError on any
|
||||
violation. This IS the R2 acceptance predicate (schema-valid + non-empty
|
||||
seed + >=1 fingerprint per participating node)."""
|
||||
|
||||
def req(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise ValueError(f"manifest schema: {msg}")
|
||||
|
||||
req(isinstance(doc, dict), "root must be an object")
|
||||
req(doc.get("schema_version") == SCHEMA_VERSION, "wrong/missing schema_version")
|
||||
for key in (
|
||||
"run_id",
|
||||
"created_utc",
|
||||
"sor_seed",
|
||||
"topology",
|
||||
"selector",
|
||||
"churn_schedule_id",
|
||||
"nodes",
|
||||
"engine",
|
||||
"git",
|
||||
"deps",
|
||||
"timestamps",
|
||||
"events",
|
||||
):
|
||||
req(key in doc, f"missing top-level key {key!r}")
|
||||
|
||||
req(isinstance(doc["run_id"], str) and doc["run_id"], "run_id must be non-empty str")
|
||||
req(isinstance(doc["sor_seed"], int), "sor_seed must be an int")
|
||||
req(0 <= doc["sor_seed"] <= (1 << 64) - 1, "sor_seed out of u64 range")
|
||||
|
||||
for f in ("topology", "selector", "churn_schedule_id"):
|
||||
req(isinstance(doc[f], str) and doc[f], f"{f} must be non-empty str")
|
||||
|
||||
nodes = doc["nodes"]
|
||||
req(isinstance(nodes, list) and len(nodes) >= 1, "nodes must be a non-empty list")
|
||||
for i, n in enumerate(nodes):
|
||||
req(isinstance(n, dict), f"node[{i}] must be an object")
|
||||
req(isinstance(n.get("role"), str) and n["role"], f"node[{i}].role required")
|
||||
fp = n.get("fingerprint")
|
||||
req(
|
||||
isinstance(fp, str) and len(fp) == 8 and all(c in "0123456789abcdef" for c in fp),
|
||||
f"node[{i}] must carry a persona fingerprint (8 hex chars)",
|
||||
)
|
||||
eng = n.get("engine")
|
||||
req(eng in ISOLATED_ENGINES, f"node[{i}].engine {eng!r} not isolated (containment)")
|
||||
|
||||
engine = doc["engine"]
|
||||
req(isinstance(engine, dict), "engine must be an object")
|
||||
req(engine.get("kind") in ISOLATED_ENGINES, "engine.kind not isolated (containment)")
|
||||
|
||||
ts = doc["timestamps"]
|
||||
req(isinstance(ts, dict) and isinstance(ts.get("start_utc"), str) and ts["start_utc"],
|
||||
"timestamps.start_utc required")
|
||||
|
||||
ev = doc["events"]
|
||||
req(isinstance(ev, dict) and "sha256" in ev and "path" in ev, "events block malformed")
|
||||
Reference in New Issue
Block a user