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
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
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.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]

View file

@ -118,3 +118,24 @@ def test_the_schemas_ship_inside_the_package(repo):
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"):
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))
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
# ---- 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, "GET", "/project-run/nope")[0] == 404
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
id: RBT-WP-0004-T04
status: todo
status: done
priority: medium
state_hub_task_id: "616255c2-091c-5948-8160-abd9dc69907f"
```
Show the run record, export it as non-secret JSON, and produce the exercise-status
diff from RBT-WP-0003-T04.
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.
The exercise-status diff no longer exists (retired, RBT-WP-0009-T01).
## Fix the check, record and lock-in cycle

View file

@ -66,12 +66,12 @@ RBT-WP-0006 and NK-WP-0011 to backlog).
```task
id: RBT-WP-0009-T04
status: todo
status: done
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;
the download is refused for a run that is still open).
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
refused (409). Added `rtut seal <file>` so an exported record can be verified anywhere.
## Console mapping: is v0.2 expressive enough