Evidence export of closed runs and rtut seal (RBT-WP-0009-T04)

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

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 21:35:03 +02:00
parent e021fd979f
commit 6d70f4d97d
12 changed files with 149 additions and 19 deletions

View file

@ -174,6 +174,8 @@ def main(argv: list[str] | None = None) -> int:
sp = sub.add_parser("ui", help="serve the local UI on 127.0.0.1")
sp.add_argument("roots", nargs="*", default=["."])
sp.add_argument("--port", type=int, default=8877)
sp = sub.add_parser("seal", help="check the seal of an exported run or project-run record (JSON file)")
sp.add_argument("record")
sp = sub.add_parser("check", help="validate every pack, attribute set, project and template under a repository")
sp.add_argument("root", nargs="?", default=".")
sp = sub.add_parser("maturity", help="derived maturity of a pack at its current revision")
@ -213,6 +215,15 @@ def main(argv: list[str] | None = None) -> int:
except KeyboardInterrupt:
pass
return 0
if a.cmd == "seal":
import json
from . import run as R
try:
state = R.record_seal_state(json.loads(Path(a.record).read_text()))
except (OSError, ValueError) as e:
print(f"error: {e}", file=sys.stderr); return 1
print(state)
return {"sealed": 0, "unsealed": 2, "open": 2, "broken": 1}[state]
if a.cmd == "check":
return _check(a.root)
if a.cmd == "project":

View file

@ -321,10 +321,7 @@ def finalize(reg: Registry, pr: ProjectRun, outcome: str, note: str = "") -> Non
def seal_state(pr: ProjectRun) -> str:
disk = _read_json(pr.path) or pr.data
if not disk.get("outcome"):
return "open"
return "sealed" if disk.get("seal") == _seal_of(disk) else "broken"
return R.record_seal_state(_read_json(pr.path) or pr.data)
def times(pr: ProjectRun, now: datetime | None = None) -> dict:

View file

@ -374,16 +374,21 @@ def _read_json(path) -> dict | None:
return None
def record_seal_state(data: dict) -> str:
"""'open' | 'sealed' | 'broken' | 'unsealed' for a run or project-run record, judged from its content alone,
so an exported record can be checked by anyone (`rtut seal <file>`)."""
if not data.get("outcome"):
return "open"
if not data.get("seal"):
return "unsealed"
return "sealed" if data["seal"] == _seal_of(data) else "broken"
def seal_state(run: Run) -> str:
"""'open' | 'sealed' | 'broken' (changed after closing, outside the app) | 'unsealed' (closed before sealing existed).
Judged on what is on disk, not on the in-memory copy."""
disk = _read_json(run.path) or run.data
if not disk.get("outcome"):
return "open"
if not disk.get("seal"):
return "unsealed"
return "sealed" if disk["seal"] == _seal_of(disk) else "broken"
return record_seal_state(_read_json(run.path) or run.data)
def finalize(run: Run, outcome: str, note: str = "", rating: str | None = None, comment: str = "") -> None:

View file

@ -301,7 +301,9 @@ class App:
outcome = r.data.get("outcome")
if outcome:
note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else ""
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
box = (f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
f"<p><a href='/run/{i}/{e(rid)}/record.json'>Download the run record (JSON)</a> "
"<span class=note>Check it anywhere with <code>rtut seal &lt;file&gt;</code>.</span></p>")
return f"<div id=close class=step><h2>Close this run</h2>{box}</div>"
states = [v["state"] for v in r.data["steps"].values()]
btns = []
@ -480,6 +482,21 @@ def make_handler(app: App, port_holder: list):
self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"")
self.end_headers(); self.wfile.write(body)
def _send_record(self, path, filename: str):
"""A closed record, exactly as stored, as a download. Its seal can be checked with `rtut seal <file>`."""
import json
data = json.loads(path.read_text())
if not data.get("outcome"):
return self._send_file(409, b"the record can be downloaded once the run is closed", "text/plain; charset=utf-8", None)
state = R.record_seal_state(data)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Rtut-Seal", state)
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
self.send_header("Content-Disposition", f"attachment; filename=\"{filename}\"")
self.end_headers(); self.wfile.write(path.read_bytes())
def _host_ok(self) -> bool:
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost"))
@ -506,6 +523,10 @@ def make_handler(app: App, port_holder: list):
body = UA.set_page(app, parts[1])
if body:
return self._send(body)
if parts[0] == "run" and len(parts) == 4 and parts[3] == "record.json":
return self._send_record(R.load(app.packs[int(parts[1])], parts[2]).path, f"run-{parts[2]}.json")
if parts[0] == "project-run" and len(parts) == 3 and parts[2] == "record.json":
return self._send_record(PRUN.load(app.registry(), parts[1]).path, f"project-run-{parts[1]}.json")
if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact":
status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4])
want_dl = bool(parse_qs(urlparse(self.path).query).get("download"))

View file

@ -162,7 +162,9 @@ def _close(app, reg, pr, v: dict) -> str:
note = f"<p>Comment: {e(d['outcome_note'])}</p>" if d.get("outcome_note") else ""
broken = ("<p class=bad>⚠ This project run's record was changed outside the app after it was sealed.</p>" if state == "broken" else
"<p class=note>This project run is <b>closed and locked</b>: its record is sealed and cannot be changed. Start a new project run to try again.</p>")
return f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}</div>"
return (f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}"
f"<p><a href='/project-run/{e(pr.id)}/record.json'>Download the project run record (JSON)</a> "
"<span class=note>Check it anywhere with <code>rtut seal &lt;file&gt;</code>.</span></p></div>")
btns = []
if all(r["status"] == "succeeded" for r in v["members"]):
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")

View file

@ -605,7 +605,9 @@ def _close(c: Ctx) -> str:
if c.finished:
note = f"<p>Comment: {e(run.data['outcome_note'])}</p>" if run.data.get("outcome_note") else ""
return (f"<div id=close class=step><h2>Run finished: {e(run.data['outcome'])}</h2>{note}{c.app.locked_note(run)}"
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p></div>")
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p>"
f"<p><a href='{c.base}/record.json'>Download the run record (JSON)</a> "
"<span class=note>Check it anywhere with <code>rtut seal &lt;file&gt;</code>.</span></p></div>")
fl = c.fl
steps = [run.data["steps"][s]["state"] for s in fl.steps]
gates = [run.data["gates"][g]["state"] for g in fl.gates]