2026-07-03 08:51:03 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import re
|
2026-07-03 09:24:13 +00:00
|
|
|
import shlex
|
2026-07-03 08:51:03 +00:00
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from .config import STANDARD_LEAN_AXIOMS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True)
|
|
|
|
|
class LeanTools:
|
|
|
|
|
lean: str | None
|
|
|
|
|
lake: str | None
|
|
|
|
|
lean_version: str | None = None
|
|
|
|
|
lake_version: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True)
|
|
|
|
|
class LeanCheckResult:
|
|
|
|
|
attempted: bool
|
|
|
|
|
ok: bool
|
|
|
|
|
missing_tool: str | None
|
|
|
|
|
checked_files: list[str] = field(default_factory=list)
|
|
|
|
|
failed_files: list[str] = field(default_factory=list)
|
|
|
|
|
log_path: str | None = None
|
|
|
|
|
diagnostics: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True)
|
|
|
|
|
class CertificateAxiomResult:
|
|
|
|
|
name: str
|
|
|
|
|
status: str
|
|
|
|
|
axiom_status: str
|
|
|
|
|
observed_axioms: list[str]
|
|
|
|
|
expected_axioms: list[str]
|
|
|
|
|
diagnostics: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True)
|
|
|
|
|
class AxiomAuditResult:
|
|
|
|
|
attempted: bool
|
|
|
|
|
ok: bool
|
|
|
|
|
missing_tool: str | None
|
|
|
|
|
certificates: list[CertificateAxiomResult]
|
|
|
|
|
log_path: str | None
|
|
|
|
|
diagnostics: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:24:13 +00:00
|
|
|
def detect_tools(env: dict[str, str] | None = None) -> LeanTools:
|
|
|
|
|
path = env.get("PATH") if env else None
|
|
|
|
|
lean = shutil.which("lean", path=path)
|
|
|
|
|
lake = shutil.which("lake", path=path)
|
2026-07-03 08:51:03 +00:00
|
|
|
return LeanTools(
|
|
|
|
|
lean=lean,
|
|
|
|
|
lake=lake,
|
2026-07-03 09:24:13 +00:00
|
|
|
lean_version=_version([lean, "--version"], env=env) if lean else None,
|
|
|
|
|
lake_version=_version([lake, "--version"], env=env) if lake else None,
|
2026-07-03 08:51:03 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:24:13 +00:00
|
|
|
def build_lean_env(
|
|
|
|
|
verification_dir: str | Path,
|
|
|
|
|
base_env: dict[str, str] | None = None,
|
|
|
|
|
env_script: str | Path | None = None,
|
|
|
|
|
) -> dict[str, str]:
|
2026-07-03 08:51:03 +00:00
|
|
|
verification = Path(verification_dir).resolve()
|
2026-07-03 09:24:13 +00:00
|
|
|
env = _base_env(base_env, env_script)
|
2026-07-03 08:51:03 +00:00
|
|
|
candidates = [verification / "gen", verification]
|
|
|
|
|
existing = [str(path) for path in candidates if path.exists()]
|
|
|
|
|
old = env.get("LEAN_PATH")
|
|
|
|
|
if old:
|
|
|
|
|
existing.append(old)
|
|
|
|
|
if existing:
|
|
|
|
|
env["LEAN_PATH"] = os.pathsep.join(existing)
|
|
|
|
|
return env
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:24:13 +00:00
|
|
|
def env_script_available(env_script: str | Path | None) -> tuple[bool, str | None]:
|
|
|
|
|
if not env_script:
|
|
|
|
|
return True, None
|
|
|
|
|
path = Path(str(env_script)).expanduser()
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return False, f"Verifier environment script does not exist: {path}"
|
|
|
|
|
return True, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_lean_project_dir(path: str | Path | None, env: dict[str, str] | None = None) -> Path | None:
|
|
|
|
|
return _resolve_project_dir(path, env or os.environ)
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 08:51:03 +00:00
|
|
|
def build_lean_invocation(
|
|
|
|
|
file_path: str | Path,
|
|
|
|
|
tools: LeanTools,
|
|
|
|
|
use_lake_env: bool = False,
|
|
|
|
|
output_path: str | Path | None = None,
|
2026-07-03 11:03:58 +00:00
|
|
|
root_path: str | Path | None = None,
|
2026-07-06 08:42:59 +00:00
|
|
|
lean_guard: str | Path | None = None,
|
2026-07-03 08:51:03 +00:00
|
|
|
) -> list[str]:
|
2026-07-06 08:42:59 +00:00
|
|
|
if lean_guard:
|
|
|
|
|
# MACHINE PROTECTION: route the compile through the repo's lean-guard
|
|
|
|
|
# (hard memory cap via systemd scope + lean -M, core pinning, timeout,
|
|
|
|
|
# single-flight lock, free-RAM preflight with a retry ladder). The
|
|
|
|
|
# guard replaces the bare `lean` binary entirely and computes its own
|
|
|
|
|
# olean output path; caps are tuned via LEAN_MEM_MB, LEAN_MIN_FREE_MB,
|
|
|
|
|
# LEAN_MEM_WAIT_SEC, LEAN_TIMEOUT, LEAN_MAX_CORES in the environment.
|
|
|
|
|
guarded = [str(lean_guard), str(file_path)]
|
|
|
|
|
if root_path is not None:
|
|
|
|
|
# lean-guard forwards extra args to lean after the file; --root
|
|
|
|
|
# lets absolute file paths live outside the toolchain project dir.
|
|
|
|
|
guarded.append(f"--root={root_path}")
|
|
|
|
|
if use_lake_env and tools.lake:
|
|
|
|
|
return [tools.lake, "env", *guarded]
|
|
|
|
|
return guarded
|
2026-07-03 08:51:03 +00:00
|
|
|
args = ["lean"]
|
2026-07-03 11:03:58 +00:00
|
|
|
if root_path is not None:
|
|
|
|
|
args.append(f"--root={root_path}")
|
2026-07-03 08:51:03 +00:00
|
|
|
if output_path is not None:
|
|
|
|
|
args.extend(["-o", str(output_path)])
|
|
|
|
|
args.append(str(file_path))
|
|
|
|
|
if use_lake_env and tools.lake:
|
|
|
|
|
return [tools.lake, "env", *args]
|
|
|
|
|
if not tools.lean:
|
|
|
|
|
raise RuntimeError("lean is not available")
|
|
|
|
|
return [tools.lean, *args[1:]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lean_check_files(
|
|
|
|
|
files: list[Path],
|
|
|
|
|
verification_dir: str | Path,
|
|
|
|
|
timeout: int = 120,
|
|
|
|
|
log_dir: str | Path | None = None,
|
2026-07-03 09:24:13 +00:00
|
|
|
env_script: str | Path | None = None,
|
|
|
|
|
lean_project_dir: str | Path | None = None,
|
2026-07-06 08:42:59 +00:00
|
|
|
lean_guard: str | Path | None = None,
|
2026-07-03 08:51:03 +00:00
|
|
|
) -> LeanCheckResult:
|
|
|
|
|
if not files:
|
|
|
|
|
return LeanCheckResult(
|
|
|
|
|
attempted=False,
|
|
|
|
|
ok=False,
|
|
|
|
|
missing_tool=None,
|
|
|
|
|
diagnostics=[f"No Lean files discovered under {verification_dir}."],
|
|
|
|
|
)
|
2026-07-03 09:24:13 +00:00
|
|
|
ok_env, env_error = env_script_available(env_script)
|
|
|
|
|
if not ok_env:
|
|
|
|
|
return LeanCheckResult(
|
|
|
|
|
attempted=False,
|
|
|
|
|
ok=False,
|
|
|
|
|
missing_tool="env_script",
|
|
|
|
|
diagnostics=[
|
|
|
|
|
env_error or "Verifier environment script is not available.",
|
|
|
|
|
"Install or point --env-script at the pinned Lean/Aeneas environment; no extraction will be run.",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
env = build_lean_env(verification_dir, env_script=env_script)
|
|
|
|
|
tools = detect_tools(env)
|
2026-07-03 08:51:03 +00:00
|
|
|
if not tools.lean and not tools.lake:
|
|
|
|
|
return LeanCheckResult(
|
|
|
|
|
attempted=False,
|
|
|
|
|
ok=False,
|
|
|
|
|
missing_tool="lean",
|
|
|
|
|
diagnostics=["Neither lean nor lake was found on PATH."],
|
|
|
|
|
)
|
2026-07-03 09:24:13 +00:00
|
|
|
verification = Path(verification_dir).resolve()
|
|
|
|
|
project_dir = _resolve_project_dir(lean_project_dir, env)
|
|
|
|
|
use_lake_env = tools.lake is not None and (
|
|
|
|
|
project_dir is not None or (verification / "lakefile.lean").exists() or (verification / "lakefile.toml").exists()
|
|
|
|
|
)
|
|
|
|
|
cwd = project_dir or verification
|
2026-07-03 08:51:03 +00:00
|
|
|
logs = _log_file(log_dir, "lean-check.log")
|
|
|
|
|
checked: list[str] = []
|
|
|
|
|
failed: list[str] = []
|
|
|
|
|
diagnostics: list[str] = []
|
|
|
|
|
with logs.open("w", encoding="utf-8") as log:
|
|
|
|
|
log.write(f"lean: {tools.lean}\n")
|
|
|
|
|
log.write(f"lake: {tools.lake}\n")
|
|
|
|
|
log.write(f"lean_version: {tools.lean_version}\n")
|
|
|
|
|
log.write(f"lake_version: {tools.lake_version}\n\n")
|
2026-07-03 09:24:13 +00:00
|
|
|
log.write(f"env_script: {env_script or ''}\n")
|
|
|
|
|
log.write(f"lean_project_dir: {project_dir or ''}\n\n")
|
2026-07-03 08:51:03 +00:00
|
|
|
for path in files:
|
2026-07-03 11:03:58 +00:00
|
|
|
cmd = build_lean_invocation(
|
|
|
|
|
path,
|
|
|
|
|
tools,
|
|
|
|
|
use_lake_env=use_lake_env,
|
|
|
|
|
output_path=path.with_suffix(".olean"),
|
|
|
|
|
root_path=_lean_root_for_file(path, verification),
|
2026-07-06 08:42:59 +00:00
|
|
|
lean_guard=lean_guard,
|
2026-07-03 11:03:58 +00:00
|
|
|
)
|
2026-07-03 08:51:03 +00:00
|
|
|
log.write("$ " + " ".join(cmd) + "\n")
|
|
|
|
|
try:
|
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
check=False,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=timeout,
|
2026-07-03 09:24:13 +00:00
|
|
|
cwd=str(cwd),
|
2026-07-03 08:51:03 +00:00
|
|
|
env=env,
|
|
|
|
|
)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
failed.append(str(path))
|
|
|
|
|
message = f"Timed out after {timeout}s: {path}"
|
|
|
|
|
diagnostics.append(message)
|
|
|
|
|
log.write(message + "\n")
|
|
|
|
|
continue
|
|
|
|
|
checked.append(str(path))
|
|
|
|
|
log.write(completed.stdout)
|
|
|
|
|
log.write(completed.stderr)
|
|
|
|
|
log.write(f"\nexit_code: {completed.returncode}\n\n")
|
|
|
|
|
if completed.returncode != 0:
|
|
|
|
|
failed.append(str(path))
|
2026-07-03 09:24:13 +00:00
|
|
|
diagnostics.extend(_dependency_diagnostics(completed.stdout + completed.stderr))
|
2026-07-03 08:51:03 +00:00
|
|
|
return LeanCheckResult(
|
|
|
|
|
attempted=True,
|
|
|
|
|
ok=not failed,
|
|
|
|
|
missing_tool=None,
|
|
|
|
|
checked_files=checked,
|
|
|
|
|
failed_files=failed,
|
|
|
|
|
log_path=str(logs),
|
|
|
|
|
diagnostics=diagnostics,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_axiom_audit(
|
|
|
|
|
verification_dir: str | Path,
|
|
|
|
|
imports: list[str],
|
|
|
|
|
certificates: list[str],
|
|
|
|
|
expected_axioms: list[str] | None = None,
|
|
|
|
|
timeout: int = 120,
|
|
|
|
|
log_dir: str | Path | None = None,
|
2026-07-03 09:24:13 +00:00
|
|
|
env_script: str | Path | None = None,
|
|
|
|
|
lean_project_dir: str | Path | None = None,
|
Estate sync: boundary-axiom vocabulary + the four-tier apex reality (R4)
The verified corpus completed its phase 2 on 2026-07-06: every ed25519
fork now carries FOUR button-enforced apex tiers up to the full lift
(accept <=> decompress(R) = [k](-A)+[s]B as points), the complete scalar
layer, and the constructive encoding/decoding chain. pacta was calibrated
to the pre-apex corpus and - worse - had no vocabulary for
boundary-audited certificates: its axiom audit knew only "clean = exactly
the three standard axioms", so the apex tiers would have scored dirty.
New vocabulary:
- Profile.certificate_axioms: per-certificate ALLOWED axiom sets;
expected_axioms_for(cert) resolves each certificate's own boundary.
- RepoConfig.apex_boundary: a simple per-fork key (dalek-wrappers /
hash3 / anza) expanded by the ed25519 profile into the exact
per-tier allowed sets. AUTHORITY NOTE in profiles/ed25519.py: each
repo's check.sh Phase 3b is the enforcement point; if the button and
this table disagree, the button wins.
- run_axiom_audit compares each certificate against ITS allowed set;
deviation in EITHER direction (extra axiom or missing boundary
axiom) is dirty.
New risk reality:
- R4 is now reachable: full four-tier apex + constructive chain +
scalar arithmetic, all proven with cones pinned to their documented
boundaries. R4 always carries explicit residual blockers (SHA-512
oracle, hypothesis-parametric wire parses, translation faithfulness,
no side-channel/build assurance - those gate R5).
- R3 unchanged (arithmetic pair) and now explains exactly which apex
certificates are missing for R4.
Attestation trust model hardened:
- The provider is trusted for its OBSERVATION, never its VERDICT:
axiom_status is re-derived locally from observed_axioms against the
agent's own boundary policy. A provider that labels a dirty cone
"clean" gains nothing; "proven" with no observed axioms is
"unverifiable".
- Partial attestations degrade instead of being rejected: uncovered
certificates stay unproven and the score caps accordingly (an
arithmetic-only attestation still authorizes an R3 library capsule,
never a wallet).
Also: scripts/mini_pytest.py - a dependency-free test runner (tmp_path,
raises, monkeypatch, capsys) for hosts without pytest; examples
regenerated FROM the tool (dalek/anza fixtures now R4, 16 certs; new
full four-tier attestation example); tests updated + new
tests/test_boundaries.py (lying-provider, missing-boundary-axiom,
partial-coverage cases). 40/40 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:04:43 +00:00
|
|
|
certificate_axioms: dict[str, list[str]] | None = None,
|
2026-07-06 08:42:59 +00:00
|
|
|
lean_guard: str | Path | None = None,
|
2026-07-03 08:51:03 +00:00
|
|
|
) -> AxiomAuditResult:
|
|
|
|
|
expected = expected_axioms or STANDARD_LEAN_AXIOMS
|
Estate sync: boundary-axiom vocabulary + the four-tier apex reality (R4)
The verified corpus completed its phase 2 on 2026-07-06: every ed25519
fork now carries FOUR button-enforced apex tiers up to the full lift
(accept <=> decompress(R) = [k](-A)+[s]B as points), the complete scalar
layer, and the constructive encoding/decoding chain. pacta was calibrated
to the pre-apex corpus and - worse - had no vocabulary for
boundary-audited certificates: its axiom audit knew only "clean = exactly
the three standard axioms", so the apex tiers would have scored dirty.
New vocabulary:
- Profile.certificate_axioms: per-certificate ALLOWED axiom sets;
expected_axioms_for(cert) resolves each certificate's own boundary.
- RepoConfig.apex_boundary: a simple per-fork key (dalek-wrappers /
hash3 / anza) expanded by the ed25519 profile into the exact
per-tier allowed sets. AUTHORITY NOTE in profiles/ed25519.py: each
repo's check.sh Phase 3b is the enforcement point; if the button and
this table disagree, the button wins.
- run_axiom_audit compares each certificate against ITS allowed set;
deviation in EITHER direction (extra axiom or missing boundary
axiom) is dirty.
New risk reality:
- R4 is now reachable: full four-tier apex + constructive chain +
scalar arithmetic, all proven with cones pinned to their documented
boundaries. R4 always carries explicit residual blockers (SHA-512
oracle, hypothesis-parametric wire parses, translation faithfulness,
no side-channel/build assurance - those gate R5).
- R3 unchanged (arithmetic pair) and now explains exactly which apex
certificates are missing for R4.
Attestation trust model hardened:
- The provider is trusted for its OBSERVATION, never its VERDICT:
axiom_status is re-derived locally from observed_axioms against the
agent's own boundary policy. A provider that labels a dirty cone
"clean" gains nothing; "proven" with no observed axioms is
"unverifiable".
- Partial attestations degrade instead of being rejected: uncovered
certificates stay unproven and the score caps accordingly (an
arithmetic-only attestation still authorizes an R3 library capsule,
never a wallet).
Also: scripts/mini_pytest.py - a dependency-free test runner (tmp_path,
raises, monkeypatch, capsys) for hosts without pytest; examples
regenerated FROM the tool (dalek/anza fixtures now R4, 16 certs; new
full four-tier attestation example); tests updated + new
tests/test_boundaries.py (lying-provider, missing-boundary-axiom,
partial-coverage cases). 40/40 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:04:43 +00:00
|
|
|
per_cert = certificate_axioms or {}
|
|
|
|
|
|
|
|
|
|
def expected_for(cert: str) -> list[str]:
|
|
|
|
|
return list(per_cert.get(cert, expected))
|
2026-07-03 09:24:13 +00:00
|
|
|
ok_env, env_error = env_script_available(env_script)
|
|
|
|
|
if not ok_env:
|
|
|
|
|
cert_results = [
|
Estate sync: boundary-axiom vocabulary + the four-tier apex reality (R4)
The verified corpus completed its phase 2 on 2026-07-06: every ed25519
fork now carries FOUR button-enforced apex tiers up to the full lift
(accept <=> decompress(R) = [k](-A)+[s]B as points), the complete scalar
layer, and the constructive encoding/decoding chain. pacta was calibrated
to the pre-apex corpus and - worse - had no vocabulary for
boundary-audited certificates: its axiom audit knew only "clean = exactly
the three standard axioms", so the apex tiers would have scored dirty.
New vocabulary:
- Profile.certificate_axioms: per-certificate ALLOWED axiom sets;
expected_axioms_for(cert) resolves each certificate's own boundary.
- RepoConfig.apex_boundary: a simple per-fork key (dalek-wrappers /
hash3 / anza) expanded by the ed25519 profile into the exact
per-tier allowed sets. AUTHORITY NOTE in profiles/ed25519.py: each
repo's check.sh Phase 3b is the enforcement point; if the button and
this table disagree, the button wins.
- run_axiom_audit compares each certificate against ITS allowed set;
deviation in EITHER direction (extra axiom or missing boundary
axiom) is dirty.
New risk reality:
- R4 is now reachable: full four-tier apex + constructive chain +
scalar arithmetic, all proven with cones pinned to their documented
boundaries. R4 always carries explicit residual blockers (SHA-512
oracle, hypothesis-parametric wire parses, translation faithfulness,
no side-channel/build assurance - those gate R5).
- R3 unchanged (arithmetic pair) and now explains exactly which apex
certificates are missing for R4.
Attestation trust model hardened:
- The provider is trusted for its OBSERVATION, never its VERDICT:
axiom_status is re-derived locally from observed_axioms against the
agent's own boundary policy. A provider that labels a dirty cone
"clean" gains nothing; "proven" with no observed axioms is
"unverifiable".
- Partial attestations degrade instead of being rejected: uncovered
certificates stay unproven and the score caps accordingly (an
arithmetic-only attestation still authorizes an R3 library capsule,
never a wallet).
Also: scripts/mini_pytest.py - a dependency-free test runner (tmp_path,
raises, monkeypatch, capsys) for hosts without pytest; examples
regenerated FROM the tool (dalek/anza fixtures now R4, 16 certs; new
full four-tier attestation example); tests updated + new
tests/test_boundaries.py (lying-provider, missing-boundary-axiom,
partial-coverage cases). 40/40 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:04:43 +00:00
|
|
|
CertificateAxiomResult(cert, "unknown", "not_checked", [], expected_for(cert), [env_error or "Verifier environment unavailable."])
|
2026-07-03 09:24:13 +00:00
|
|
|
for cert in certificates
|
|
|
|
|
]
|
|
|
|
|
return AxiomAuditResult(False, False, "env_script", cert_results, None, [env_error or "Verifier environment unavailable."])
|
|
|
|
|
env = build_lean_env(verification_dir, env_script=env_script)
|
|
|
|
|
tools = detect_tools(env)
|
2026-07-03 08:51:03 +00:00
|
|
|
if not tools.lean and not tools.lake:
|
|
|
|
|
cert_results = [
|
Estate sync: boundary-axiom vocabulary + the four-tier apex reality (R4)
The verified corpus completed its phase 2 on 2026-07-06: every ed25519
fork now carries FOUR button-enforced apex tiers up to the full lift
(accept <=> decompress(R) = [k](-A)+[s]B as points), the complete scalar
layer, and the constructive encoding/decoding chain. pacta was calibrated
to the pre-apex corpus and - worse - had no vocabulary for
boundary-audited certificates: its axiom audit knew only "clean = exactly
the three standard axioms", so the apex tiers would have scored dirty.
New vocabulary:
- Profile.certificate_axioms: per-certificate ALLOWED axiom sets;
expected_axioms_for(cert) resolves each certificate's own boundary.
- RepoConfig.apex_boundary: a simple per-fork key (dalek-wrappers /
hash3 / anza) expanded by the ed25519 profile into the exact
per-tier allowed sets. AUTHORITY NOTE in profiles/ed25519.py: each
repo's check.sh Phase 3b is the enforcement point; if the button and
this table disagree, the button wins.
- run_axiom_audit compares each certificate against ITS allowed set;
deviation in EITHER direction (extra axiom or missing boundary
axiom) is dirty.
New risk reality:
- R4 is now reachable: full four-tier apex + constructive chain +
scalar arithmetic, all proven with cones pinned to their documented
boundaries. R4 always carries explicit residual blockers (SHA-512
oracle, hypothesis-parametric wire parses, translation faithfulness,
no side-channel/build assurance - those gate R5).
- R3 unchanged (arithmetic pair) and now explains exactly which apex
certificates are missing for R4.
Attestation trust model hardened:
- The provider is trusted for its OBSERVATION, never its VERDICT:
axiom_status is re-derived locally from observed_axioms against the
agent's own boundary policy. A provider that labels a dirty cone
"clean" gains nothing; "proven" with no observed axioms is
"unverifiable".
- Partial attestations degrade instead of being rejected: uncovered
certificates stay unproven and the score caps accordingly (an
arithmetic-only attestation still authorizes an R3 library capsule,
never a wallet).
Also: scripts/mini_pytest.py - a dependency-free test runner (tmp_path,
raises, monkeypatch, capsys) for hosts without pytest; examples
regenerated FROM the tool (dalek/anza fixtures now R4, 16 certs; new
full four-tier attestation example); tests updated + new
tests/test_boundaries.py (lying-provider, missing-boundary-axiom,
partial-coverage cases). 40/40 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:04:43 +00:00
|
|
|
CertificateAxiomResult(cert, "unknown", "not_checked", [], expected_for(cert), ["Neither lean nor lake was found on PATH."])
|
2026-07-03 08:51:03 +00:00
|
|
|
for cert in certificates
|
|
|
|
|
]
|
|
|
|
|
return AxiomAuditResult(False, False, "lean", cert_results, None, ["Neither lean nor lake was found on PATH."])
|
2026-07-03 09:24:13 +00:00
|
|
|
verification = Path(verification_dir).resolve()
|
|
|
|
|
project_dir = _resolve_project_dir(lean_project_dir, env)
|
|
|
|
|
use_lake_env = tools.lake is not None and (
|
|
|
|
|
project_dir is not None or (verification / "lakefile.lean").exists() or (verification / "lakefile.toml").exists()
|
|
|
|
|
)
|
|
|
|
|
cwd = project_dir or verification
|
2026-07-03 08:51:03 +00:00
|
|
|
logs = _log_file(log_dir, "axiom-audit.log")
|
|
|
|
|
imports_text = "\n".join(f"import {module}" for module in imports)
|
|
|
|
|
prints_text = "\n".join(f"#print axioms {cert}" for cert in certificates)
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="pacta-axioms-") as tmp:
|
|
|
|
|
audit_file = Path(tmp) / "AxiomAudit.lean"
|
|
|
|
|
audit_file.write_text(f"{imports_text}\n\n{prints_text}\n", encoding="utf-8")
|
2026-07-06 10:51:59 +00:00
|
|
|
cmd = build_lean_invocation(
|
|
|
|
|
audit_file,
|
|
|
|
|
tools,
|
|
|
|
|
use_lake_env=use_lake_env,
|
|
|
|
|
lean_guard=lean_guard,
|
|
|
|
|
# the audit file lives in a temp dir outside the toolchain root;
|
|
|
|
|
# --root makes lean accept it (guard mode forwards the flag).
|
|
|
|
|
root_path=audit_file.parent,
|
|
|
|
|
)
|
2026-07-03 08:51:03 +00:00
|
|
|
try:
|
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
check=False,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=timeout,
|
2026-07-03 09:24:13 +00:00
|
|
|
cwd=str(cwd),
|
2026-07-03 08:51:03 +00:00
|
|
|
env=env,
|
|
|
|
|
)
|
|
|
|
|
output = completed.stdout + completed.stderr
|
|
|
|
|
return_code = completed.returncode
|
|
|
|
|
except subprocess.TimeoutExpired as exc:
|
|
|
|
|
output = (exc.stdout or "") + (exc.stderr or "") + f"\nTimed out after {timeout}s\n"
|
|
|
|
|
return_code = 124
|
|
|
|
|
logs.write_text(output, encoding="utf-8")
|
|
|
|
|
parsed = parse_axiom_output(output, certificates)
|
fix: fail-closed certificate classification + record-scoped axiom parsing (review round 6)
R6-B1 (Claude, executed end-to-end): provenness was decided by a
WHOLE-OUTPUT 'no axioms' sentence, so an axiom-free certificate whose
line was entirely absent still scored proven+clean ([]==[]). The
reviewer drove a doctored 60-line output through the real gate and got
61/61 with domsep never audited. Classification is now extracted into
classify_certificates(): proven iff the certificate's OWN anchor was
parsed (axiom-free anchors populate []); absent certs are
unknown/failed + not_checked — never clean. This also fail-closes the
typo'd-future-cert case (R6-C2).
GPT §6: parse_axiom_output is now RECORD-scoped — anchors delimit
records, a cone bracket is accepted only inside its own record, missing
or truncated brackets yield MISSING (fail closed) instead of borrowing
the next certificate's bracket, and cones may wrap arbitrarily (the
old fixed 16-line window was a latent overflow for the 11-axiom
ed25519 apex cones on this estate). Anchor names are captured between
the exact quotes Lean prints.
Six new regression tests (absent-axiom-free-not-clean, missing-bracket
no-steal, truncated cone, >16-line wrap, duplicate anchor, interleaved
diagnostics). Suite: 114 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:15:58 +00:00
|
|
|
cert_results = classify_certificates(parsed, certificates, return_code, expected_for)
|
2026-07-03 08:51:03 +00:00
|
|
|
return AxiomAuditResult(
|
|
|
|
|
attempted=True,
|
|
|
|
|
ok=return_code == 0 and all(cert.axiom_status == "clean" for cert in cert_results),
|
|
|
|
|
missing_tool=None,
|
|
|
|
|
certificates=cert_results,
|
|
|
|
|
log_path=str(logs),
|
2026-07-03 09:24:13 +00:00
|
|
|
diagnostics=[] if return_code == 0 else [f"Lean axiom audit exited with {return_code}.", *_dependency_diagnostics(output)],
|
2026-07-03 08:51:03 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
fix: fail-closed certificate classification + record-scoped axiom parsing (review round 6)
R6-B1 (Claude, executed end-to-end): provenness was decided by a
WHOLE-OUTPUT 'no axioms' sentence, so an axiom-free certificate whose
line was entirely absent still scored proven+clean ([]==[]). The
reviewer drove a doctored 60-line output through the real gate and got
61/61 with domsep never audited. Classification is now extracted into
classify_certificates(): proven iff the certificate's OWN anchor was
parsed (axiom-free anchors populate []); absent certs are
unknown/failed + not_checked — never clean. This also fail-closes the
typo'd-future-cert case (R6-C2).
GPT §6: parse_axiom_output is now RECORD-scoped — anchors delimit
records, a cone bracket is accepted only inside its own record, missing
or truncated brackets yield MISSING (fail closed) instead of borrowing
the next certificate's bracket, and cones may wrap arbitrarily (the
old fixed 16-line window was a latent overflow for the 11-axiom
ed25519 apex cones on this estate). Anchor names are captured between
the exact quotes Lean prints.
Six new regression tests (absent-axiom-free-not-clean, missing-bracket
no-steal, truncated cone, >16-line wrap, duplicate anchor, interleaved
diagnostics). Suite: 114 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:15:58 +00:00
|
|
|
# Anchor lines as Lean prints them: 'Name' depends on axioms: … /
|
|
|
|
|
# 'Name' does not depend on any axioms. The name is captured between the
|
|
|
|
|
# first pair of quotes (an identifier that itself CONTAINS a quote, e.g.
|
|
|
|
|
# Foo', would mis-capture — no such name exists on this estate; the old
|
|
|
|
|
# substring matching was strictly worse).
|
|
|
|
|
_AXIOM_ANCHOR = re.compile(r"'([^']+)'\s+(depends on axioms|does not depend on any axioms)")
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 08:51:03 +00:00
|
|
|
def parse_axiom_output(output: str, certificates: list[str]) -> dict[str, list[str]]:
|
fix: fail-closed certificate classification + record-scoped axiom parsing (review round 6)
R6-B1 (Claude, executed end-to-end): provenness was decided by a
WHOLE-OUTPUT 'no axioms' sentence, so an axiom-free certificate whose
line was entirely absent still scored proven+clean ([]==[]). The
reviewer drove a doctored 60-line output through the real gate and got
61/61 with domsep never audited. Classification is now extracted into
classify_certificates(): proven iff the certificate's OWN anchor was
parsed (axiom-free anchors populate []); absent certs are
unknown/failed + not_checked — never clean. This also fail-closes the
typo'd-future-cert case (R6-C2).
GPT §6: parse_axiom_output is now RECORD-scoped — anchors delimit
records, a cone bracket is accepted only inside its own record, missing
or truncated brackets yield MISSING (fail closed) instead of borrowing
the next certificate's bracket, and cones may wrap arbitrarily (the
old fixed 16-line window was a latent overflow for the 11-axiom
ed25519 apex cones on this estate). Anchor names are captured between
the exact quotes Lean prints.
Six new regression tests (absent-axiom-free-not-clean, missing-bracket
no-steal, truncated cone, >16-line wrap, duplicate anchor, interleaved
diagnostics). Suite: 114 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:15:58 +00:00
|
|
|
"""Record-scoped parsing (review round 6, GPT §6 / Claude R6-B).
|
|
|
|
|
|
|
|
|
|
The output is split into RECORDS: each anchor line starts one, the
|
|
|
|
|
next anchor line ends it. A cone bracket is accepted only inside its
|
|
|
|
|
own record; a record whose bracket is missing or truncated yields a
|
|
|
|
|
MISSING certificate (fail closed at the caller), never a bracket
|
|
|
|
|
borrowed from the next record. Cones may wrap arbitrarily many lines
|
|
|
|
|
(the ed25519 apex tiers carry 11 axioms — the old fixed 16-line
|
|
|
|
|
window was a latent overflow for them). Duplicate anchors: first one
|
|
|
|
|
wins, deterministically.
|
|
|
|
|
"""
|
2026-07-03 08:51:03 +00:00
|
|
|
lines = output.splitlines()
|
fix: fail-closed certificate classification + record-scoped axiom parsing (review round 6)
R6-B1 (Claude, executed end-to-end): provenness was decided by a
WHOLE-OUTPUT 'no axioms' sentence, so an axiom-free certificate whose
line was entirely absent still scored proven+clean ([]==[]). The
reviewer drove a doctored 60-line output through the real gate and got
61/61 with domsep never audited. Classification is now extracted into
classify_certificates(): proven iff the certificate's OWN anchor was
parsed (axiom-free anchors populate []); absent certs are
unknown/failed + not_checked — never clean. This also fail-closes the
typo'd-future-cert case (R6-C2).
GPT §6: parse_axiom_output is now RECORD-scoped — anchors delimit
records, a cone bracket is accepted only inside its own record, missing
or truncated brackets yield MISSING (fail closed) instead of borrowing
the next certificate's bracket, and cones may wrap arbitrarily (the
old fixed 16-line window was a latent overflow for the 11-axiom
ed25519 apex cones on this estate). Anchor names are captured between
the exact quotes Lean prints.
Six new regression tests (absent-axiom-free-not-clean, missing-bracket
no-steal, truncated cone, >16-line wrap, duplicate anchor, interleaved
diagnostics). Suite: 114 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:15:58 +00:00
|
|
|
anchors: list[tuple[int, str, bool]] = [] # (line index, name, axiom-free?)
|
|
|
|
|
for i, line in enumerate(lines):
|
|
|
|
|
m = _AXIOM_ANCHOR.search(line)
|
|
|
|
|
if m:
|
|
|
|
|
anchors.append((i, m.group(1), "does not depend" in m.group(2)))
|
|
|
|
|
wanted = set(certificates)
|
|
|
|
|
results: dict[str, list[str]] = {}
|
|
|
|
|
for k, (i, name, axiom_free) in enumerate(anchors):
|
|
|
|
|
if name not in wanted or name in results:
|
|
|
|
|
continue
|
|
|
|
|
if axiom_free:
|
|
|
|
|
results[name] = []
|
|
|
|
|
continue
|
|
|
|
|
end = anchors[k + 1][0] if k + 1 < len(anchors) else len(lines)
|
|
|
|
|
record = "\n".join(lines[i:end])
|
|
|
|
|
bracket = re.search(r"\[([^\]]*)\]", record, re.DOTALL)
|
|
|
|
|
if bracket:
|
|
|
|
|
results[name] = [item.strip() for item in bracket.group(1).split(",") if item.strip()]
|
|
|
|
|
# else: no complete bracket before the next record — leave MISSING.
|
2026-07-03 08:51:03 +00:00
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
fix: fail-closed certificate classification + record-scoped axiom parsing (review round 6)
R6-B1 (Claude, executed end-to-end): provenness was decided by a
WHOLE-OUTPUT 'no axioms' sentence, so an axiom-free certificate whose
line was entirely absent still scored proven+clean ([]==[]). The
reviewer drove a doctored 60-line output through the real gate and got
61/61 with domsep never audited. Classification is now extracted into
classify_certificates(): proven iff the certificate's OWN anchor was
parsed (axiom-free anchors populate []); absent certs are
unknown/failed + not_checked — never clean. This also fail-closes the
typo'd-future-cert case (R6-C2).
GPT §6: parse_axiom_output is now RECORD-scoped — anchors delimit
records, a cone bracket is accepted only inside its own record, missing
or truncated brackets yield MISSING (fail closed) instead of borrowing
the next certificate's bracket, and cones may wrap arbitrarily (the
old fixed 16-line window was a latent overflow for the 11-axiom
ed25519 apex cones on this estate). Anchor names are captured between
the exact quotes Lean prints.
Six new regression tests (absent-axiom-free-not-clean, missing-bracket
no-steal, truncated cone, >16-line wrap, duplicate anchor, interleaved
diagnostics). Suite: 114 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:15:58 +00:00
|
|
|
def classify_certificates(
|
|
|
|
|
parsed: dict[str, list[str]],
|
|
|
|
|
certificates: list[str],
|
|
|
|
|
return_code: int,
|
|
|
|
|
expected_for,
|
|
|
|
|
) -> list[CertificateAxiomResult]:
|
|
|
|
|
"""Fail-closed per-certificate classification (review round 6, R6-B1).
|
|
|
|
|
|
|
|
|
|
Provenness is decided by the certificate's OWN anchor having been
|
|
|
|
|
found (membership in `parsed` — which includes axiom-free certs as
|
|
|
|
|
[]), never by a whole-output "no axioms" sentence: an ABSENT
|
|
|
|
|
axiom-free certificate previously scored proven+clean because some
|
|
|
|
|
OTHER certificate's bracketless sentence satisfied the global check
|
|
|
|
|
and [] == [] satisfied the cone comparison. Absent certificates are
|
|
|
|
|
never clean.
|
|
|
|
|
"""
|
|
|
|
|
out: list[CertificateAxiomResult] = []
|
|
|
|
|
for cert in certificates:
|
|
|
|
|
if cert in parsed:
|
|
|
|
|
observed = parsed[cert]
|
|
|
|
|
status = "proven"
|
|
|
|
|
axiom_status = "clean" if sorted(observed) == sorted(expected_for(cert)) else "dirty"
|
|
|
|
|
else:
|
|
|
|
|
observed = []
|
|
|
|
|
status = "failed" if return_code != 0 else "unknown"
|
|
|
|
|
axiom_status = "not_checked"
|
|
|
|
|
out.append(CertificateAxiomResult(cert, status, axiom_status, observed, expected_for(cert)))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:24:13 +00:00
|
|
|
def _version(cmd: list[str | None], env: dict[str, str] | None = None) -> str | None:
|
2026-07-03 08:51:03 +00:00
|
|
|
if not cmd[0]:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
[part for part in cmd if part],
|
|
|
|
|
check=False,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=10,
|
2026-07-03 09:24:13 +00:00
|
|
|
env=env,
|
2026-07-03 08:51:03 +00:00
|
|
|
)
|
|
|
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
|
|
|
return None
|
|
|
|
|
if completed.returncode != 0:
|
|
|
|
|
return None
|
|
|
|
|
return (completed.stdout or completed.stderr).strip() or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log_file(log_dir: str | Path | None, name: str) -> Path:
|
|
|
|
|
directory = Path(log_dir) if log_dir else Path(".pacta")
|
|
|
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
return directory / name
|
2026-07-03 09:24:13 +00:00
|
|
|
|
|
|
|
|
|
2026-07-03 11:03:58 +00:00
|
|
|
def _lean_root_for_file(path: Path, verification_dir: Path) -> Path:
|
|
|
|
|
gen_dir = verification_dir / "gen"
|
|
|
|
|
try:
|
|
|
|
|
path.relative_to(gen_dir)
|
|
|
|
|
return gen_dir
|
|
|
|
|
except ValueError:
|
|
|
|
|
return verification_dir
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:24:13 +00:00
|
|
|
def _base_env(base_env: dict[str, str] | None, env_script: str | Path | None) -> dict[str, str]:
|
|
|
|
|
env = dict(base_env or os.environ)
|
|
|
|
|
if not env_script:
|
|
|
|
|
return env
|
|
|
|
|
path = Path(str(env_script)).expanduser()
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return env
|
|
|
|
|
command = f"source {shlex.quote(str(path))}; env"
|
|
|
|
|
try:
|
|
|
|
|
completed = subprocess.run(
|
2026-07-03 11:03:58 +00:00
|
|
|
["/bin/bash", "-lc", command],
|
2026-07-03 09:24:13 +00:00
|
|
|
check=False,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=20,
|
|
|
|
|
env=env,
|
|
|
|
|
)
|
|
|
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
|
|
|
return env
|
|
|
|
|
if completed.returncode != 0:
|
|
|
|
|
return env
|
|
|
|
|
for line in completed.stdout.splitlines():
|
|
|
|
|
if "=" in line:
|
|
|
|
|
key, value = line.split("=", 1)
|
|
|
|
|
env[key] = value
|
|
|
|
|
return env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_project_dir(path: str | Path | None, env: dict[str, str]) -> Path | None:
|
|
|
|
|
if not path:
|
|
|
|
|
return None
|
|
|
|
|
expanded = os.path.expandvars(str(path))
|
|
|
|
|
for key, value in env.items():
|
|
|
|
|
expanded = expanded.replace(f"${key}", value).replace(f"${{{key}}}", value)
|
|
|
|
|
resolved = Path(expanded).expanduser()
|
|
|
|
|
return resolved if resolved.exists() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dependency_diagnostics(output: str) -> list[str]:
|
|
|
|
|
diagnostics: list[str] = []
|
|
|
|
|
modules = sorted(set(re.findall(r"unknown module prefix '([^']+)'", output)))
|
|
|
|
|
for module in modules:
|
|
|
|
|
diagnostics.append(f"Missing Lean dependency/module prefix: {module}")
|
|
|
|
|
missing_objects = sorted(set(re.findall(r"object file '([^']+)' of module ([A-Za-z0-9_'.]+) does not exist", output)))
|
|
|
|
|
for _, module in missing_objects[:8]:
|
|
|
|
|
diagnostics.append(f"Missing compiled Lean object for module: {module}")
|
|
|
|
|
return diagnostics
|