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:
leetcrypt
2026-07-19 16:15:06 -07:00
parent c04f9aed23
commit 7ed23704ea
3 changed files with 372 additions and 0 deletions
+251
View File
@@ -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")
+10
View File
@@ -155,4 +155,14 @@ mod tests {
assert_eq!(fp.len(), 8, "4 bytes → 8 hex chars");
assert_eq!(Some(fp), fingerprint_of(&p.pub_b64()));
}
// Cross-language parity anchor for the R2 manifest writer: the same known
// pubkey (raw bytes 0..32) maps to this fingerprint in
// cmd_chat/sor/provenance.py (tests/test_sor_provenance.py). If either side
// changes the fingerprint formula, one of the two tests fails.
#[test]
fn fingerprint_of_known_vector() {
let pub_b64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
assert_eq!(fingerprint_of(pub_b64).as_deref(), Some("630dcd29"));
}
}
+111
View File
@@ -0,0 +1,111 @@
"""R2 — provenance manifest acceptance checks.
Acceptance predicate (roadmap R2): a manifest is present + schema-valid after a
run, carries a non-empty seed, and >=1 persona fingerprint per participating
node. The fingerprint parity constant below is locked identically in
hh/src/persona.rs::tests::fingerprint_of_known_vector.
"""
import json
import pytest
from cmd_chat.sor.provenance import (
Node,
RunManifest,
node_fingerprint,
validate_manifest,
write_manifest,
)
# Known 32-byte pubkey (bytes 0..32) -> base64 -> fingerprint. Same vector as
# the Rust persona test.
KNOWN_PUB_B64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
KNOWN_FINGERPRINT = "630dcd29"
def _fixture_manifest(root):
return RunManifest(
run_id="20260719T000000Z",
sor_seed=123456789,
topology="1-house",
selector="static",
churn_schedule_id="fixture-churn-0",
nodes=[
Node(role="host", persona_pub_b64=KNOWN_PUB_B64, engine="docker"),
Node(role="hop", persona_pub_b64="QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNk", engine="docker"),
],
engine_kind="docker",
engine_image_digest="sha256:deadbeef",
worktree_root=root,
)
def test_fingerprint_parity_with_rust():
assert node_fingerprint(KNOWN_PUB_B64) == KNOWN_FINGERPRINT
def test_manifest_written_and_schema_valid(tmp_path):
run_dir = tmp_path / "sor-runs" / "20260719T000000Z"
doc = write_manifest(run_dir, _fixture_manifest(tmp_path))
# present on disk
path = run_dir / "manifest.json"
assert path.is_file()
on_disk = json.loads(path.read_text())
assert on_disk == doc
# schema-valid, non-empty seed, >=1 fingerprint per node (the R2 predicate)
validate_manifest(on_disk)
assert on_disk["sor_seed"] == 123456789
assert len(on_disk["nodes"]) >= 1
for n in on_disk["nodes"]:
assert n["fingerprint"]
assert on_disk["nodes"][0]["fingerprint"] == KNOWN_FINGERPRINT
def test_manifest_is_immutable(tmp_path):
run_dir = tmp_path / "run"
write_manifest(run_dir, _fixture_manifest(tmp_path))
with pytest.raises(FileExistsError):
write_manifest(run_dir, _fixture_manifest(tmp_path))
def test_containment_rejects_local_engine(tmp_path):
m = _fixture_manifest(tmp_path)
m.engine_kind = "local"
with pytest.raises(ValueError):
write_manifest(tmp_path / "run2", m)
def test_containment_rejects_local_node_engine(tmp_path):
m = _fixture_manifest(tmp_path)
m.nodes[1].engine = "local"
with pytest.raises(ValueError):
write_manifest(tmp_path / "run3", m)
def test_invalid_pubkey_rejected():
with pytest.raises(ValueError):
node_fingerprint("!!! not base64 !!!")
def test_validator_catches_empty_nodes():
with pytest.raises(ValueError):
validate_manifest(
{
"schema_version": "sor-manifest/1",
"run_id": "x",
"created_utc": "t",
"sor_seed": 1,
"topology": "1-house",
"selector": "static",
"churn_schedule_id": "c",
"nodes": [],
"engine": {"kind": "docker", "image_digest": None},
"git": {},
"deps": {},
"timestamps": {"start_utc": "t", "stop_utc": None},
"events": {"path": "events.jsonl", "sha256": None},
}
)