net-kingdom/tools/execution-attribution-receipt/validate.py
tegwick d7133e3a04 NK-WP-0056-T15: receipt schema v0.1, validator and executor fixtures; audit sender exercise
- canon/schemas/execution-attribution-receipt_v0.1.schema.json: the bounded-executor profile
  of the PCC v0.2 receipt, both audit-core events (started, receipt), every object closed.
- tools/execution-attribution-receipt: EAR-1..EAR-10 validator, fixtures generated from the
  real executor (completed, reverted, completed-on-retry) with a drift check.
- executor: started carries receipt_version, digests, steps_planned and data.window.not_after
  (audit-core AUDIT-WP-0018-T03 request); receipt carries receipt_version and initiator.iss
  (configured approver_issuer, else unknown).
- bounded-executor/exercise_audit_sender.py: real executor and client against audit-core's
  real ingestion and the Git scope overlay: 202, duplicate 200, conflict 409 as a defect,
  wrong source/tenant/token refused and not stored, a receipt-less start flagged by its own
  window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 523928@bnt-lap001
Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350
2026-11-04 01:39:57 +01:00

147 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Execution Attribution Receipt validator, bounded-executor profile (NK-WP-0056-T15, NK-WP-0040-T02 scope).
Offline and deterministic. Checks the two audit-core events of one run against
canon/schemas/execution-attribution-receipt_v0.1.schema.json (EAR-1), then the cross-field rules:
EAR-2 envelope id is run:<correlation_id>:started|receipt, and data.run_id equals correlation_id
EAR-3 subject names the run's admission (started: data.admission; receipt: data.target)
EAR-4 receipt: envelope tenant equals data.tenant
EAR-5 receipt: decision_refs are admission, admission_digest, approval, once each; admission matches the subject
EAR-6 receipt: time_interval.start <= end, and occurred_at equals end
EAR-7 receipt: reverted is true exactly when a revert step was recorded
EAR-8 receipt: attempts equals budget.used.attempts and does not exceed budget.limit.attempts
EAR-9 receipt: outcome agrees with the steps (completed: the final attempt, after the last revert, has every
step done and every check passed, and a revert implies a retry; reverted: a revert ran and every revert
step is done)
EAR-10 pair: started and receipt share run_id, tenant, admission and approval, the started admission_digest is the
receipt's, started precedes the receipt, and the run ended inside the admission window
Findings name a rule and a location, never a value.
python3 tools/execution-attribution-receipt/validate.py validate EVENT.json...
python3 tools/execution-attribution-receipt/validate.py pair STARTED.json RECEIPT.json
"""
from __future__ import annotations
import datetime as dt
import json
import pathlib
import sys
from typing import Any
import jsonschema
ROOT = pathlib.Path(__file__).resolve().parents[2]
SCHEMA = ROOT / "canon/schemas/execution-attribution-receipt_v0.1.schema.json"
STARTED = "execution.run.started"
RECEIPT = "execution.attribution.receipt"
def parse_time(value: str) -> dt.datetime:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
def _schema() -> dict:
return json.loads(SCHEMA.read_text(encoding="utf-8"))
def evaluate(event: Any, schema: dict | None = None) -> dict:
"""One event. Returns {"valid": bool, "kind": started|receipt|None, "findings": [{"rule", "at"}]}."""
schema = schema or _schema()
findings: list[dict] = []
def fail(rule: str, at: str) -> None:
findings.append({"rule": rule, "at": at})
validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker())
errors = sorted(validator.iter_errors(event), key=lambda e: list(e.absolute_path))
if errors:
for error in errors[:20]:
fail("EAR-1", "/".join(str(p) for p in error.absolute_path) or "(root)")
return {"valid": False, "kind": None, "findings": findings}
kind = "started" if event["type"] == STARTED else "receipt"
data = event["data"]
if event["id"] != f"run:{event['correlation_id']}:{kind}" or data["run_id"] != event["correlation_id"]:
fail("EAR-2", "id")
admission = data["admission"] if kind == "started" else data["target"].removeprefix("admission:")
if event["subject"] != f"admission:{admission}":
fail("EAR-3", "subject")
if kind == "receipt":
if event["tenant"] != data["tenant"]:
fail("EAR-4", "tenant")
refs = {r["label"]: r["ref"] for r in data["decision_refs"]}
if sorted(r["label"] for r in data["decision_refs"]) != ["admission", "admission_digest", "approval"] \
or refs.get("admission") != admission or not refs.get("admission_digest", "").startswith("sha256:"):
fail("EAR-5", "data/decision_refs")
start, end = (parse_time(data["time_interval"][k]) for k in ("start", "end"))
if start > end or parse_time(event["occurred_at"]) != end:
fail("EAR-6", "data/time_interval")
reverts = [s for s in data["steps"] if s["kind"] == "revert"]
if data["reverted"] != bool(reverts):
fail("EAR-7", "data/reverted")
budget = data["budget"]
if data["attempts"] != budget["used"]["attempts"] or data["attempts"] > budget["limit"]["attempts"]:
fail("EAR-8", "data/budget")
if data["outcome"] == "completed":
# A clean revert may be retried (BEA budget.attempts): earlier attempts may hold failures and reverts.
# The final attempt is the steps after the last revert; it must be complete and clean.
last = max((i for i, s in enumerate(data["steps"]) if s["kind"] == "revert"), default=-1)
final = data["steps"][last + 1:]
if (not final or (reverts and data["attempts"] < 2)
or any(s["outcome"] != "done" or any(c["result"] != "pass" for c in s["checks"]) for s in final)):
fail("EAR-9", "data/outcome")
if data["outcome"] == "reverted" and (not reverts or any(s["outcome"] != "done" for s in reverts)):
fail("EAR-9", "data/outcome")
return {"valid": not findings, "kind": kind, "findings": findings}
def evaluate_pair(started: Any, receipt: Any, schema: dict | None = None) -> dict:
schema = schema or _schema()
findings = []
for name, event, want in (("started", started, "started"), ("receipt", receipt, "receipt")):
report = evaluate(event, schema)
findings += [{"rule": f["rule"], "at": f"{name}:{f['at']}"} for f in report["findings"]]
if report["valid"] and report["kind"] != want:
findings.append({"rule": "EAR-10", "at": f"{name}:type"})
if findings:
return {"valid": False, "findings": findings}
s, r = started["data"], receipt["data"]
refs = {x["label"]: x["ref"] for x in r["decision_refs"]}
checks = {
"run_id": s["run_id"] == r["run_id"],
"tenant": started["tenant"] == receipt["tenant"],
"admission": started["subject"] == receipt["subject"],
"admission_digest": s["admission_digest"] == refs["admission_digest"],
"approval": s["approval"] == refs["approval"],
"order": parse_time(started["occurred_at"]) <= parse_time(receipt["occurred_at"]),
"window": parse_time(r["time_interval"]["end"]) <= parse_time(s["window"]["not_after"]),
}
findings = [{"rule": "EAR-10", "at": k} for k, ok in checks.items() if not ok]
return {"valid": not findings, "findings": findings}
def _load(path: str) -> Any:
return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) >= 2 and argv[0] == "validate":
ok = True
for path in argv[1:]:
report = evaluate(_load(path))
print(json.dumps({"file": path, **report}, sort_keys=True))
ok &= report["valid"]
return 0 if ok else 1
if len(argv) == 3 and argv[0] == "pair":
report = evaluate_pair(_load(argv[1]), _load(argv[2]))
print(json.dumps(report, sort_keys=True))
return 0 if report["valid"] else 1
print(__doc__.split("\n\n")[-1], file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())