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

@ -119,6 +119,15 @@ Closing a run (`finalize`) makes its record final:
A broken seal is called out in the UI, and such a run no longer counts as maturity evidence. This is tamper-evidence, not prevention: someone A broken seal is called out in the UI, and such a run no longer counts as maturity evidence. This is tamper-evidence, not prevention: someone
with file access can still edit a file, but not without being noticed. with file access can still edit a file, but not without being noticed.
## Exporting a closed run as evidence
A closed run's record can be downloaded as JSON from its page (`GET /run/<i>/<run>/record.json`; project runs: `/project-run/<id>/record.json`),
exactly as stored. An open run has no final record (409). The response carries `X-Rtut-Seal` (`sealed`, `broken` or `unsealed`), so a record that
was changed on disk is still downloadable but flagged. **`rtut seal <file>` checks an exported record anywhere**: it recomputes the SHA-256 over
the record (without `seal` and `updated`) and prints `sealed` (exit 0), `broken` (1), or `unsealed`/`open` (2). The record is non-secret by
construction (secret-looking text is refused everywhere it could be recorded); it does include any output the operator chose to capture, so
read it before sharing.
## Changing a value after steps used it ## Changing a value after steps used it
A v0.1 run fixes its parameters when it starts. In a v0.2 run datapoints can be edited (guided flow questions, data view), but a value A v0.1 run fixes its parameters when it starts. In a v0.2 run datapoints can be edited (guided flow questions, data view), but a value

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 = sub.add_parser("ui", help="serve the local UI on 127.0.0.1")
sp.add_argument("roots", nargs="*", default=["."]) sp.add_argument("roots", nargs="*", default=["."])
sp.add_argument("--port", type=int, default=8877) 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 = sub.add_parser("check", help="validate every pack, attribute set, project and template under a repository")
sp.add_argument("root", nargs="?", default=".") sp.add_argument("root", nargs="?", default=".")
sp = sub.add_parser("maturity", help="derived maturity of a pack at its current revision") 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: except KeyboardInterrupt:
pass pass
return 0 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": if a.cmd == "check":
return _check(a.root) return _check(a.root)
if a.cmd == "project": 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: def seal_state(pr: ProjectRun) -> str:
disk = _read_json(pr.path) or pr.data return R.record_seal_state(_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"
def times(pr: ProjectRun, now: datetime | None = None) -> dict: def times(pr: ProjectRun, now: datetime | None = None) -> dict:

View file

@ -374,16 +374,21 @@ def _read_json(path) -> dict | None:
return 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: def seal_state(run: Run) -> str:
"""'open' | 'sealed' | 'broken' (changed after closing, outside the app) | 'unsealed' (closed before sealing existed). """'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.""" Judged on what is on disk, not on the in-memory copy."""
disk = _read_json(run.path) or run.data return record_seal_state(_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"
def finalize(run: Run, outcome: str, note: str = "", rating: str | None = None, comment: str = "") -> None: 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") outcome = r.data.get("outcome")
if outcome: if outcome:
note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else "" 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>" return f"<div id=close class=step><h2>Close this run</h2>{box}</div>"
states = [v["state"] for v in r.data["steps"].values()] states = [v["state"] for v in r.data["steps"].values()]
btns = [] btns = []
@ -480,6 +482,21 @@ def make_handler(app: App, port_holder: list):
self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"") self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"")
self.end_headers(); self.wfile.write(body) 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: def _host_ok(self) -> bool:
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost")) 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]) body = UA.set_page(app, parts[1])
if body: if body:
return self._send(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": 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]) 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")) 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 "" 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 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>") "<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 = [] btns = []
if all(r["status"] == "succeeded" for r in v["members"]): if all(r["status"] == "succeeded" for r in v["members"]):
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>") btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")

View file

@ -605,7 +605,9 @@ def _close(c: Ctx) -> str:
if c.finished: if c.finished:
note = f"<p>Comment: {e(run.data['outcome_note'])}</p>" if run.data.get("outcome_note") else "" 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)}" 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 fl = c.fl
steps = [run.data["steps"][s]["state"] for s in fl.steps] steps = [run.data["steps"][s]["state"] for s in fl.steps]
gates = [run.data["gates"][g]["state"] for g in fl.gates] gates = [run.data["gates"][g]["state"] for g in fl.gates]

View file

@ -118,3 +118,24 @@ def test_the_schemas_ship_inside_the_package(repo):
from rtut import pack, v2, attrs from rtut import pack, v2, attrs
for path in (pack.SCHEMA_PATH, v2.SCHEMA_PATH, attrs.SCHEMA_DIR / "project.schema.json", attrs.SCHEMA_DIR / "attribute-set.schema.json"): for path in (pack.SCHEMA_PATH, v2.SCHEMA_PATH, attrs.SCHEMA_DIR / "project.schema.json", attrs.SCHEMA_DIR / "attribute-set.schema.json"):
assert path.exists() and "rtut/schema" in str(path) assert path.exists() and "rtut/schema" in str(path)
def test_seal_command_reports_sealed_broken_open_and_unsealed(repo, capsys, tmp_path):
import json
from rtut import pack as P, run as R1
pack = P.load(repo / "runbooks/v1/pack.yaml")
r = R1.create(pack, "bernd")
f = tmp_path / "rec.json"
f.write_text(r.path.read_text())
code, out, _ = run(capsys, "seal", f)
assert code == 2 and out.strip() == "open" # nothing to check yet
R1.finalize(r, "abandoned")
f.write_text(r.path.read_text())
code, out, _ = run(capsys, "seal", f)
assert code == 0 and out.strip() == "sealed"
d = json.loads(f.read_text()); d["operator"] = "forged"; f.write_text(json.dumps(d))
assert run(capsys, "seal", f)[:2] == (1, "broken\n")
d = json.loads(r.path.read_text()); d.pop("seal"); f.write_text(json.dumps(d))
assert run(capsys, "seal", f)[:2] == (2, "unsealed\n")
code, _, err = run(capsys, "seal", tmp_path / "missing.json")
assert code == 1 and "error" in err

View file

@ -157,3 +157,48 @@ def test_the_record_file_is_read_only_after_closing_and_tampering_is_called_out(
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data)) data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
body = req(srv, "GET", base)[2] body = req(srv, "GET", base)[2]
assert "changed outside the app after it was sealed" in body and "no longer counts towards maturity" in body assert "changed outside the app after it was sealed" in body and "no longer counts towards maturity" in body
# ---- evidence export ------------------------------------------------------------------------------------------------------
def raw(s, path):
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
c.request("GET", path, None, {"Host": "127.0.0.1"})
r = c.getresponse()
return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read()
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
def test_a_closed_run_record_can_be_downloaded_and_its_seal_checked_elsewhere(srv, pack, tmp_path, capsys):
from rtut.__main__ import main
base, _ = start(srv, pack)
st, _, body = raw(srv, f"{base}/record.json")
assert st == 409 and b"once the run is closed" in body # an open run has no final record
assert "Download the run record" not in req(srv, "GET", base)[2]
post(srv, f"{base}/finalize", outcome="abandoned", note="for the record")
st, hdr, body = raw(srv, f"{base}/record.json")
rid = base.rsplit("/", 1)[1]
assert st == 200 and hdr["content-type"] == "application/json" and hdr["x-rtut-seal"] == "sealed"
assert hdr["content-disposition"] == f'attachment; filename="run-{rid}.json"' and hdr["x-content-type-options"] == "nosniff"
assert "sandbox" in hdr["content-security-policy"]
assert body == run_of(srv, base).path.read_bytes() # exactly as stored
record = json.loads(body)
assert record["run_id"] == rid and record["outcome"] == "abandoned" and R1.record_seal_state(record) == "sealed"
assert f"<a href='{base}/record.json'>Download the run record (JSON)</a>" in req(srv, "GET", base)[2]
f = tmp_path / "exported.json"; f.write_bytes(body)
assert main(["seal", str(f)]) == 0 and capsys.readouterr().out.strip() == "sealed" # anyone can verify it
record["operator"] = "forged"; f.write_text(json.dumps(record))
assert main(["seal", str(f)]) == 1 and capsys.readouterr().out.strip() == "broken"
def test_a_tampered_record_is_still_downloadable_but_flagged_in_the_header(srv):
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
post(srv, f"{base}/finalize", outcome="abandoned")
r = run_of(srv, base); r.path.chmod(0o644)
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
st, hdr, _ = raw(srv, f"{base}/record.json")
assert st == 200 and hdr["x-rtut-seal"] == "broken" # evidence, with the warning attached
def test_unknown_run_record_is_not_found(srv):
assert raw(srv, "/run/0/nope/record.json")[0] == 404

View file

@ -228,3 +228,20 @@ def test_sidebar_marks_projects_on_a_project_run_page_and_csrf_and_unknown_ids(s
assert req(srv, "POST", "/project/demo/runall", {"mode": "manual"})[0] == 403 assert req(srv, "POST", "/project/demo/runall", {"mode": "manual"})[0] == 403
assert req(srv, "GET", "/project-run/nope")[0] == 404 assert req(srv, "GET", "/project-run/nope")[0] == 404
assert post(srv, "/project-run/nope/tick", n="1")[0] == 400 assert post(srv, "/project-run/nope/tick", n="1")[0] == 400
def test_a_closed_project_run_record_can_be_downloaded(srv):
import json
from rtut import run as RR
loc = start_pr(srv)
c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1])
c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"})
assert c.getresponse().status == 409 # open: no final record yet
post(srv, f"{loc}/finalize", outcome="abandoned")
assert f"<a href='{loc}/record.json'>Download the project run record (JSON)</a>" in req(srv, "GET", loc)[2]
c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1])
c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"})
r = c.getresponse(); body = r.read()
assert r.status == 200 and r.getheader("X-Rtut-Seal") == "sealed" and "attachment" in r.getheader("Content-Disposition")
rec = json.loads(body)
assert rec["project"] == "demo" and rec["outcome"] == "abandoned" and RR.record_seal_state(rec) == "sealed"

View file

@ -61,13 +61,13 @@ without a browser.
```task ```task
id: RBT-WP-0004-T04 id: RBT-WP-0004-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "616255c2-091c-5948-8160-abd9dc69907f" state_hub_task_id: "616255c2-091c-5948-8160-abd9dc69907f"
``` ```
Show the run record, export it as non-secret JSON, and produce the exercise-status Done 2026-09-29 (RBT-WP-0009-T04): the run view is the evidence view; a closed run's record downloads as JSON and `rtut seal` verifies it.
diff from RBT-WP-0003-T04. The exercise-status diff no longer exists (retired, RBT-WP-0009-T01).
## Fix the check, record and lock-in cycle ## Fix the check, record and lock-in cycle

View file

@ -66,12 +66,12 @@ RBT-WP-0006 and NK-WP-0011 to backlog).
```task ```task
id: RBT-WP-0009-T04 id: RBT-WP-0009-T04
status: todo status: done
priority: medium priority: medium
``` ```
Close RBT-WP-0004-T04: a closed run's record can be downloaded as JSON from its page (the record is non-secret by construction; Done 2026-09-29, closing RBT-WP-0004-T04: a closed run's (and project run's) record can be downloaded as JSON from its page; an open run is
the download is refused for a run that is still open). refused (409). Added `rtut seal <file>` so an exported record can be verified anywhere.
## Console mapping: is v0.2 expressive enough ## Console mapping: is v0.2 expressive enough