Retire exercise_status: maturity is the only notion; add CLI tests (RBT-WP-0009-T01)

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:28:56 +02:00
parent b89e8eaa74
commit 6d3414d8b7
29 changed files with 444 additions and 218 deletions

View file

@ -23,8 +23,8 @@ verification and rollback, and non-secret evidence of what actually happened.
- **Parameters, not copies.** A runbook declares its parameters; the engine - **Parameters, not copies.** A runbook declares its parameters; the engine
renders commands with the operator's values and never asks for secrets. renders commands with the operator's values and never asks for secrets.
- **Evidence over assertion.** A step is done when its declared check has - **Evidence over assertion.** A step is done when its declared check has
passed and its outcome is recorded. A runbook that was never run is labelled passed and its outcome is recorded. How far a runbook can be trusted (its
unexercised, following the attended-procedure standard. maturity) is earned from clean runs in named environments, not declared.
- **Never a secret vault.** The engine does not collect, store, log or - **Never a secret vault.** The engine does not collect, store, log or
transmit secret values. It shows the command; the operator runs it. transmit secret values. It shows the command; the operator runs it.
- **Guide, not executor by default.** Steps are run by the operator. Any - **Guide, not executor by default.** Steps are run by the operator. Any

View file

@ -11,7 +11,7 @@ can guide through actual IT administration and operation tasks.
```bash ```bash
python3 -m rtut validate <repo-root> # validate every pack python3 -m rtut validate <repo-root> # validate every pack
python3 -m rtut list <repo-root> # id, engine, exercise status, title python3 -m rtut list <repo-root> # id, engine, maturity, title
python3 -m rtut show <pack.yaml> # steps with owners and done-when python3 -m rtut show <pack.yaml> # steps with owners and done-when
python3 -m rtut render <pack.yaml> <step> -p name=value python3 -m rtut render <pack.yaml> <step> -p name=value
python3 -m rtut launch <pack.yaml> # legacy-command packs: start the existing console python3 -m rtut launch <pack.yaml> # legacy-command packs: start the existing console
@ -19,10 +19,8 @@ python3 -m rtut maturity <pack.yaml> # draft | reviewed | rehearsed | prove
python3 -m rtut review <pack.yaml> --by you # record a review of the current revision python3 -m rtut review <pack.yaml> --by you # record a review of the current revision
python3 -m rtut project list|validate|status|attrs|new|run <repo-root> ... # projects, attribute sets, templates python3 -m rtut project list|validate|status|attrs|new|run <repo-root> ... # projects, attribute sets, templates
python3 -m rtut ui <repo-root> [<repo-root> ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks; v0.1 and v0.2 runs (guided flow, data view, overview) python3 -m rtut ui <repo-root> [<repo-root> ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks; v0.1 and v0.2 runs (guided flow, data view, overview)
python3 -m rtut run new|start|verify|done|finish <pack.yaml> ... # the same run record from the terminal python3 -m rtut run new|start|verify|done|finalize <pack.yaml> ... # the same run record from the terminal
make test make test
``` ```
Status: specification, validator, legacy launch, run records with verification and Status: see `SCOPE.md`; the workplans are in `workplans/`.
exercise-status diff, and a first local web UI work. Terminal walk-through mode and
the console migration are open (`workplans/`).

View file

@ -20,7 +20,7 @@ bootstrap console) working unchanged.
- Runbook pack specification, JSON Schema, and versioning rules - Runbook pack specification, JSON Schema, and versioning rules
- Pack discovery, loading, validation and conformance tests - Pack discovery, loading, validation and conformance tests
- The local UI and CLI that present a pack and record progress - The local UI and CLI that present a pack and record progress
- Evidence records (non-secret) and exercise-status tracking - Evidence records (non-secret), run outcomes and scores, and maturity derived from run history
- The legacy adapter for existing consoles - The legacy adapter for existing consoles
- Example and conformance packs - Example and conformance packs

View file

@ -91,7 +91,7 @@ A **rehearsal** project is where maturity evidence comes from. "Exercised" then
- Runs record `project`, the environment class and the pack digest. Scores become per project as well as per - Runs record `project`, the environment class and the pack digest. Scores become per project as well as per
runbook. Run records stay non-secret and local (`.local/`), so shared history remains an open question. runbook. Run records stay non-secret and local (`.local/`), so shared history remains an open question.
- `exercise_status` in `pack.yaml` is superseded. Until this is decided it stays as the interim marker. - `exercise_status` in `pack.yaml` is retired (RBT-WP-0009): the field is deprecated and ignored, and maturity is the only notion.
- Project files are declarative and non-secret. Candidate location: the repository that owns the system - Project files are declarative and non-secret. Candidate location: the repository that owns the system
(for example `net-kingdom/projects/<name>/project.yaml`), next to `runbooks/`. (for example `net-kingdom/projects/<name>/project.yaml`), next to `runbooks/`.

View file

@ -187,7 +187,7 @@ Every override, waiver, push-through and change of a value that steps already us
`kind` (`override|waiver|push-through`), `item` (step or gate id), `mode`, `kind` (`override|waiver|push-through`), `item` (step or gate id), `mode`,
`unmet` (what was not satisfied), `risk_acknowledged`, `reason` (required for `unmet` (what was not satisfied), `risk_acknowledged`, `reason` (required for
overrides and waivers; automatic text for push-through), `who`, `at`. A run with overrides and waivers; automatic text for push-through), `who`, `at`. A run with
deviations is marked so in the overview and can never exercise a pack. A **waiver** is deviations is marked so in the overview and never counts towards maturity. A **waiver** is
declared in advance (`waivable` on a gate); an **override** is not. declared in advance (`waivable` on a gate); an **override** is not.
## Decisions (2026-09-29, Bernd) ## Decisions (2026-09-29, Bernd)
@ -210,7 +210,7 @@ See `docs/maturity-and-projects.md`.
Not in v0.2. When a procedure needs another runbook (for example "OpenBao operating Not in v0.2. When a procedure needs another runbook (for example "OpenBao operating
path requires the SSH access pack"), packs will declare `requires_packs` with a path requires the SSH access pack"), packs will declare `requires_packs` with a
required result (exercised, or a named gate passed). Tracked in RBT-WP-0006. required result (a maturity level, or a named gate passed). Tracked in RBT-WP-0006.
## Recommendation (carried out) ## Recommendation (carried out)

View file

@ -14,7 +14,7 @@ under any root given on the command line.
| `id` | yes | Stable dotted id, lower-case, e.g. `nk.ssh-certificates` | | `id` | yes | Stable dotted id, lower-case, e.g. `nk.ssh-certificates` |
| `title`, `outcome` | title yes | What the operator is doing; what is true afterwards | | `title`, `outcome` | title yes | What the operator is doing; what is true afterwards |
| `owner` | yes | The repository that owns the procedure | | `owner` | yes | The repository that owns the procedure |
| `exercise_status` | yes | `unexercised` or `exercised YYYY-MM-DD by <operator>` | | `exercise_status` | no | **Deprecated and ignored.** Maturity is derived from run history; old packs that carry it still load |
| `engine` | yes | `native` or `legacy-command` | | `engine` | yes | `native` or `legacy-command` |
| `parameters` | no | Operator-supplied, non-secret values | | `parameters` | no | Operator-supplied, non-secret values |
| `prerequisites` | no | `{text, owner}` list | | `prerequisites` | no | `{text, owner}` list |
@ -54,11 +54,10 @@ A step with `risk` of `changes-state`, `attended` or `destructive` must state a
Commands are relative to the root of the repository that owns the pack. Verify Commands are relative to the root of the repository that owns the pack. Verify
commands run there, so a pack can call `make` targets and repo-relative scripts. commands run there, so a pack can call `make` targets and repo-relative scripts.
## Exercise status ## Maturity, not a status field
Following the attended-procedure standard, a pack is `unexercised` until an How far a pack can be trusted is earned, not declared: `draft`, `reviewed`, `rehearsed`, `proven`, or `stale`, derived from the
operator has completed it, and the operator is recorded. The engine displays the runs of the pack at its current revision (`docs/maturity-and-projects.md`). The earlier `exercise_status` field is retired.
status prominently and only the engine's evidence writer changes it.
## Secrets ## Secrets

View file

@ -18,14 +18,14 @@ One run of one native pack, stored as JSON in
- Steps run in order; a step starts only after its predecessors are done or skipped. - Steps run in order; a step starts only after its predecessors are done or skipped.
- A step completes only with a passing verification and its declared evidence. - A step completes only with a passing verification and its declared evidence.
- A verify command runs only when the operator confirms (`--yes`), with a 60 s timeout. - A verify command runs only when the operator confirms (`--yes`), with a 60 s timeout.
- Only read-only steps may be skipped, and a run with skips never exercises a pack. - Only read-only steps may be skipped, and a run with skips can never succeed.
- Values matching the secret markers are refused everywhere they could be recorded. - Values matching the secret markers are refused everywhere they could be recorded.
- The engine never edits `pack.yaml`. `rtut run finish` shows the exercise-status - The engine never edits `pack.yaml`. How far a pack can be trusted is not a field in it: it is derived from run history
diff; `--apply` writes it, and only for a run in which every step is done. (`rtut maturity`, `docs/maturity-and-projects.md`).
CLI: `rtut run new|list|status|cmd|start|verify|done|fail|retry|skip|finish <pack.yaml> ...` CLI: `rtut run new|list|status|cmd|start|verify|done|fail|retry|skip|feedback|finalize|capture <pack.yaml> ...`
## Outcome, score and exercise status ## Outcome and score
A run is unfinished (**stalled**) until finalized. `rtut run finalize` (or the UI's *Close this run*) records A run is unfinished (**stalled**) until finalized. `rtut run finalize` (or the UI's *Close this run*) records
an `outcome`: an `outcome`:
@ -41,11 +41,10 @@ its run records: tried, succeeded, failed, abandoned, stalled (unfinished), plus
(`rtut score <root>`; shown in the UI). Run records live in `.local/` of the machine that ran (`rtut score <root>`; shown in the UI). Run records live in `.local/` of the machine that ran
them, so the score is per machine until run histories are shared. them, so the score is per machine until run histories are shared.
Finalizing never edits the pack by itself. For a `succeeded` run the UI offers **Record pack Finalizing never edits the pack. A clean `succeeded` run becomes evidence for the pack's **maturity** at the
as exercised**, which shows what will be written, and on click sets revision it ran (`docs/maturity-and-projects.md`); this replaces the earlier `exercise_status` field and the
`exercise_status: exercised <date> by <operator>` in `pack.yaml` (`rtut run finalize ... --outcome "Record pack as exercised" step, both retired. Old packs that still carry `exercise_status` load unchanged; the
succeeded --apply` does the same). The change is an ordinary edit in the owning repository, to field is ignored.
be reviewed and committed there.
## Feedback ## Feedback
@ -96,12 +95,12 @@ Rules the engine enforces:
with `evidence` waits for the operator. If checks keep failing until the item's `timeout` (or the pack with `evidence` waits for the operator. If checks keep failing until the item's `timeout` (or the pack
default), the run stops, or with `on_timeout: push-through` the item is marked done with default), the run stops, or with `on_timeout: push-through` the item is marked done with
state `pushed` and a deviation is recorded. state `pushed` and a deviation is recorded.
- **Deviations and exercise**: a run with any deviation or pushed-through step can be finalized but never exercises a pack. - **Deviations and maturity**: a run with any deviation or pushed-through step can be finalized but never counts as maturity evidence.
`finalize(succeeded)` also needs every gate passed or waived. `finalize(succeeded)` also needs every gate passed or waived.
- **Pushed is its own state.** A step, gate or datapoint passed over by push-through is `pushed`: not `done`, - **Pushed is its own state.** A step, gate or datapoint passed over by push-through is `pushed`: not `done`,
not `set`, not `err`. Dependents may proceed (that is the point of pushing through), but a pushed datapoint not `set`, not `err`. Dependents may proceed (that is the point of pushing through), but a pushed datapoint
has no value, so a command that needs it fails its check instead of running with nothing. A run with pushed has no value, so a command that needs it fails its check instead of running with nothing. A run with pushed
items can be finished as `failed` (never `succeeded`) and never exercises a pack. items can be finished as `failed` (never `succeeded`) and never counts as maturity evidence.
- **Stalled**: an unfinished run is counted as `stalled` in the score, and as `abandoned` after a week. - **Stalled**: an unfinished run is counted as `stalled` in the score, and as `abandoned` after a week.
- **Modes** are `manual` and `auto-confirm`. Auto-confirm applies confirmation only; running the step's own - **Modes** are `manual` and `auto-confirm`. Auto-confirm applies confirmation only; running the step's own
command is a separate, later level ("auto-pilot with execution", RBT-WP-0006). command is a separate, later level ("auto-pilot with execution", RBT-WP-0006).
@ -110,12 +109,12 @@ Rules the engine enforces:
Closing a run (`finalize`) makes its record final: Closing a run (`finalize`) makes its record final:
- **Locked.** A closed record cannot be saved with changes: the engine refuses, naming what would have changed. The only later update it - **Locked.** A closed record cannot be saved with changes: the engine refuses, naming what would have changed. This holds for runs closed
accepts is `exercise_recorded`, written by the legacy "Record pack as exercised" step. This holds for runs closed before sealing existed too. before sealing existed too.
- **Feedback.** It locks with the run. Give it before closing, or with the closing form: `finalize(..., rating, comment)` records an optional - **Feedback.** It locks with the run. Give it before closing, or with the closing form: `finalize(..., rating, comment)` records an optional
rating and comment together with the outcome (validated first, so a refused close changes nothing). This keeps abandoned and failed runs from rating and comment together with the outcome (validated first, so a refused close changes nothing). This keeps abandoned and failed runs from
losing what the operator wanted to say. losing what the operator wanted to say.
- **Sealed.** The record carries a SHA-256 `seal` of its content (excluding `seal`, `updated` and `exercise_recorded`), and the file is made - **Sealed.** The record carries a SHA-256 `seal` of its content (excluding `seal` and `updated`; a legacy `exercise_recorded` key on old records is ignored), and the file is made
read-only. `seal_state` reports `open`, `sealed`, `broken` (changed outside the app after closing) or `unsealed` (closed before sealing existed). read-only. `seal_state` reports `open`, `sealed`, `broken` (changed outside the app after closing) or `unsealed` (closed before sealing existed).
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.

View file

@ -5,6 +5,7 @@ import argparse
import sys import sys
from pathlib import Path from pathlib import Path
from . import maturity as M
from . import pack as P from . import pack as P
@ -47,19 +48,12 @@ def _run(pk, a) -> int:
R.skip(r, a.step, a.note); return 0 R.skip(r, a.step, a.note); return 0
if a.action == "finalize": if a.action == "finalize":
R.finalize(r, a.outcome or "", a.note) R.finalize(r, a.outcome or "", a.note)
if r.data["outcome"] == "succeeded" and a.apply:
print("recorded:", R.apply_exercise(r))
print(r.data["outcome"]); return 0 print(r.data["outcome"]); return 0
if a.action == "capture": if a.action == "capture":
R.add_capture(r, a.step, sys.stdin.read(), a.command); print("captured"); return 0 R.add_capture(r, a.step, sys.stdin.read(), a.command); print("captured"); return 0
if a.action == "feedback": if a.action == "feedback":
R.add_feedback(r, a.rating or "", a.note); print("recorded"); return 0 R.add_feedback(r, a.rating or "", a.note); print("recorded"); return 0
old, new = R.exercise_update(r) raise ValueError(f"unknown action {a.action}")
import difflib
print("".join(difflib.unified_diff(old.splitlines(True), new.splitlines(True), "pack.yaml", "pack.yaml")))
if a.apply:
pk.path.write_text(new); print("written")
return 0
except (R.RunError, KeyError, ValueError) as e: except (R.RunError, KeyError, ValueError) as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 1 return 1
@ -125,7 +119,7 @@ def main(argv: list[str] | None = None) -> int:
sp = sub.add_parser(name) sp = sub.add_parser(name)
sp.add_argument("root", nargs="?", default=".") sp.add_argument("root", nargs="?", default=".")
sp = sub.add_parser("run", help="record a run of a native pack") sp = sub.add_parser("run", help="record a run of a native pack")
sp.add_argument("action", choices=["new", "list", "status", "cmd", "start", "verify", "done", "fail", "retry", "skip", "finish", "feedback", "finalize", "capture"]) sp.add_argument("action", choices=["new", "list", "status", "cmd", "start", "verify", "done", "fail", "retry", "skip", "feedback", "finalize", "capture"])
sp.add_argument("manifest") sp.add_argument("manifest")
sp.add_argument("run_id", nargs="?") sp.add_argument("run_id", nargs="?")
sp.add_argument("step", nargs="?") sp.add_argument("step", nargs="?")
@ -138,7 +132,6 @@ def main(argv: list[str] | None = None) -> int:
sp.add_argument("--rating", choices=["good", "neutral", "poor"], help="feedback: how the runbook felt") sp.add_argument("--rating", choices=["good", "neutral", "poor"], help="feedback: how the runbook felt")
sp.add_argument("--yes", action="store_true", help="confirm running a verify command") sp.add_argument("--yes", action="store_true", help="confirm running a verify command")
sp.add_argument("--pass", dest="passed", action="store_true", help="manual check: operator confirms it passed") sp.add_argument("--pass", dest="passed", action="store_true", help="manual check: operator confirms it passed")
sp.add_argument("--apply", action="store_true", help="finish: write the exercise-status change")
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)
@ -182,7 +175,6 @@ def main(argv: list[str] | None = None) -> int:
if a.cmd == "project": if a.cmd == "project":
return _project(a) return _project(a)
if a.cmd in ("maturity", "review"): if a.cmd in ("maturity", "review"):
from . import maturity as M
pk = P.load(Path(a.manifest)) pk = P.load(Path(a.manifest))
if not pk.ok: if not pk.ok:
print("\n".join(pk.errors), file=sys.stderr); return 1 print("\n".join(pk.errors), file=sys.stderr); return 1
@ -202,7 +194,7 @@ def main(argv: list[str] | None = None) -> int:
for p in _packs(a.root): for p in _packs(a.root):
if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1": if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1":
c = R.scorecard(p) c = R.scorecard(p)
print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\tstalled {c['stalled']}\t[{p.data['exercise_status']}]") print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\tstalled {c['stalled']}\t[{M.maturity(p)['level']}]")
return 0 return 0
if a.cmd in ("list", "validate"): if a.cmd in ("list", "validate"):
packs = _packs(a.root) packs = _packs(a.root)
@ -213,7 +205,8 @@ def main(argv: list[str] | None = None) -> int:
for p in packs: for p in packs:
d = p.data d = p.data
if a.cmd == "list": if a.cmd == "list":
print(f"{p.id}\t{d.get('engine')}\t{d.get('exercise_status')}\t{d.get('title')}") level = M.maturity(p)["level"] if p.ok and d.get("engine") == "native" else "-"
print(f"{p.id}\t{d.get('engine')}\t{level}\t{d.get('title')}")
else: else:
print(f"{'ok ' if p.ok else 'FAIL'} {p.id}") print(f"{'ok ' if p.ok else 'FAIL'} {p.id}")
for e in p.errors: for e in p.errors:
@ -246,7 +239,7 @@ def main(argv: list[str] | None = None) -> int:
return 0 return 0
if a.cmd == "show": if a.cmd == "show":
d = pk.data d = pk.data
print(f"{d['title']} [{d['exercise_status']}] engine={d['engine']}") print(f"{d['title']} [{M.maturity(pk)['level']}] engine={d['engine']}")
for i, s in enumerate(d.get("steps", []), 1): for i, s in enumerate(d.get("steps", []), 1):
print(f"{i}. [{s['owner']}] {s['title']} ({s.get('risk', 'read-only')})") print(f"{i}. [{s['owner']}] {s['title']} ({s.get('risk', 'read-only')})")
print(f" done when: {s['verify']['done_when']}") print(f" done when: {s['verify']['done_when']}")

View file

@ -143,7 +143,7 @@ REVIEW_FILE = "REVIEWS.yaml"
def digest(pack: "Pack") -> str: def digest(pack: "Pack") -> str:
"""Revision of a pack: hash of every file in its directory, except review records and local state. """Revision of a pack: hash of every file in its directory, except review records and local state.
`exercise_status:` lines are ignored, so recording an exercise never changes the revision. `exercise_status:` lines (a deprecated field) are ignored, so dropping it from a pack is not a new revision.
""" """
import hashlib import hashlib
h = hashlib.sha256() h = hashlib.sha256()

View file

@ -44,19 +44,19 @@ class Run:
def path(self) -> Path: def path(self) -> Path:
return runs_dir(self.pack) / f"{self.data['run_id']}.json" return runs_dir(self.pack) / f"{self.data['run_id']}.json"
def save(self, allow: tuple = ()) -> None: def save(self) -> None:
"""Write the record. A closed run is locked: nothing may change except the keys named in `allow`.""" """Write the record. A closed run is locked: nothing may change."""
disk = _read_json(self.path) disk = _read_json(self.path)
if disk and disk.get("outcome"): if disk and disk.get("outcome"):
keys = set(disk) | set(self.data) keys = set(disk) | set(self.data)
changed = {k for k in keys if disk.get(k) != self.data.get(k)} - {"updated"} - set(allow) changed = {k for k in keys if disk.get(k) != self.data.get(k)} - {"updated"}
if changed: if changed:
raise RunError(f"this run is closed and locked ({disk['outcome']}); it cannot be changed " raise RunError(f"this run is closed and locked ({disk['outcome']}); it cannot be changed "
f"({', '.join(sorted(changed))})") f"({', '.join(sorted(changed))})")
self.data["updated"] = _now() self.data["updated"] = _now()
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists(): if self.path.exists():
self.path.chmod(0o644) # a sealed file is read-only; the allowed update reopens it briefly self.path.chmod(0o644) # a sealed file is read-only; only the closing write reaches here
self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n") self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
if self.data.get("outcome"): if self.data.get("outcome"):
self.path.chmod(0o444) self.path.chmod(0o444)
@ -270,25 +270,6 @@ def skip(run: Run, step_id: str, reason: str) -> None:
run.save() run.save()
STATUS_LINE = re.compile(r"^exercise_status:.*$", re.M)
def exercise_update(run: Run, today: date | None = None) -> tuple[str, str]:
"""Return (old, new) pack.yaml text with exercised status. Does not write.
A run counts only if every step is done (skips do not exercise the pack).
"""
if any(v["state"] != "done" for v in run.data["steps"].values()):
raise RunError("every step must be done (not skipped) to exercise the pack")
if run.data.get("deviations"):
raise RunError("a run with deviations (overrides, waivers, push-through) cannot exercise the pack")
today = today or date.today()
old = run.pack.path.read_text()
new = STATUS_LINE.sub(f"exercise_status: exercised {today.isoformat()} by {run.data['operator']}",
old, count=1)
return old, new
RATINGS = {"good": ":)", "neutral": ":|", "poor": ":("} RATINGS = {"good": ":)", "neutral": ":|", "poor": ":("}
@ -379,7 +360,8 @@ STALE_DAYS = 7
def _seal_of(data: dict) -> str: def _seal_of(data: dict) -> str:
"""Fingerprint of a closed record. Timestamps of allowed later updates and the exercise marker are not part of it.""" """Fingerprint of a closed record. `updated` is not part of it, nor is `exercise_recorded`, which records closed
before exercise recording was retired may still carry."""
import hashlib import hashlib
body = {k: v for k, v in data.items() if k not in ("seal", "updated", "exercise_recorded")} body = {k: v for k, v in data.items() if k not in ("seal", "updated", "exercise_recorded")}
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest() return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
@ -503,16 +485,3 @@ def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_D
card["fastest_seconds"] = durations[0] if durations else None card["fastest_seconds"] = durations[0] if durations else None
card["timed_runs"] = len(durations) card["timed_runs"] = len(durations)
return card return card
def apply_exercise(run: Run, today: date | None = None) -> str:
"""Write 'exercised <date> by <operator>' into pack.yaml for a succeeded run. Returns the new status."""
if run.data.get("outcome") != "succeeded":
raise RunError("only a run finished as succeeded can exercise the pack")
_, new = exercise_update(run, today)
run.pack.path.write_text(new)
status = STATUS_LINE.search(new).group(0).split(": ", 1)[1]
run.pack.data["exercise_status"] = status
run.data["exercise_recorded"] = status
run.save(allow=("exercise_recorded",)) # the one thing a closed run may still receive
return status

View file

@ -260,7 +260,7 @@ class App:
if x.get("values") else f"<input name='p_{x['id']}' value='{e(x.get('default', ''))}'>") if x.get("values") else f"<input name='p_{x['id']}' value='{e(x.get('default', ''))}'>")
+ f"</label><br><span class=note>{e(x.get('help', ''))} Never enter secrets.</span></p>" + f"</label><br><span class=note>{e(x.get('help', ''))} Never enter secrets.</span></p>"
for x in d.get("parameters", [])) for x in d.get("parameters", []))
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>" return page(d["title"], f"<h1>{e(d['title'])}</h1><p>{UP.maturity_pill(p)}</p>"
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>" f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>" f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}" f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}"
@ -302,17 +302,6 @@ class App:
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)}"
if outcome == "succeeded":
if r.data.get("exercise_recorded"):
box += f"<p>Pack recorded as <b>{e(r.data['exercise_recorded'])}</b>. Commit the change in the owning repository.</p>"
else:
old, new = R.exercise_update(r)
line = new.split("exercise_status:", 1)[1].split("\n", 1)[0].strip()
box += (f"<p>Current pack status: <span class=pill>{e(p.data['exercise_status'])}</span>. "
f"Recording this run would set it to <b>{e(line)}</b> in <code>{e(p.path)}</code> "
"(a reviewable edit; commit it in the owning repository).</p>"
f"<form method=post action='/run/{i}/{e(rid)}/exercise'>{self.hidden()}"
"<button>Record pack as exercised</button></form>")
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 = []
@ -383,7 +372,7 @@ class App:
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>" where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}" f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}") f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}")
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>" out = [f"<h1>{e(d['title'])}</h1><p>{UP.maturity_pill(p)}</p>"
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>" f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"
f"<p class=note>{UI2.times_line(r)}</p>"] f"<p class=note>{UI2.times_line(r)}</p>"]
step_secs = R.run_times(r)["step_seconds"] step_secs = R.run_times(r)["step_seconds"]
@ -607,15 +596,10 @@ def make_handler(app: App, port_holder: list):
i, rid = int(parts[1]), parts[2] i, rid = int(parts[1]), parts[2]
R.add_feedback(R.load(app.packs[i], rid), form.get("rating", ""), form.get("comment", "")) R.add_feedback(R.load(app.packs[i], rid), form.get("rating", ""), form.get("comment", ""))
return self._redirect(f"/run/{i}/{rid}#feedback") return self._redirect(f"/run/{i}/{rid}#feedback")
if len(parts) == 4 and parts[0] == "run" and parts[3] in ("finalize", "exercise"): if len(parts) == 4 and parts[0] == "run" and parts[3] == "finalize":
i, rid = int(parts[1]), parts[2] i, rid = int(parts[1]), parts[2]
r = R.load(app.packs[i], rid) R.finalize(R.load(app.packs[i], rid), form.get("outcome", ""), form.get("note", ""),
if parts[3] == "finalize": rating=form.get("rating") or None, comment=form.get("fb_comment", ""))
R.finalize(r, form.get("outcome", ""), form.get("note", ""), rating=form.get("rating") or None,
comment=form.get("fb_comment", ""))
else:
R.apply_exercise(r)
app.packs[i] = P.load(app.packs[i].path) # pick up the new exercise status
return self._redirect(f"/run/{i}/{rid}#close") return self._redirect(f"/run/{i}/{rid}#close")
i, rid, sid, action = int(parts[1]), parts[2], parts[3], parts[4] i, rid, sid, action = int(parts[1]), parts[2], parts[3], parts[4]
p = app.packs[i]; r = R.load(p, rid) p = app.packs[i]; r = R.load(p, rid)

View file

@ -3,7 +3,6 @@ id: rtut.example-hello
title: Example - check a host answers title: Example - check a host answers
owner: runbook-tutorials owner: runbook-tutorials
outcome: You have proven a host answers on the network. outcome: You have proven a host answers on the network.
exercise_status: exercised 2026-09-29 by Bernd
engine: native engine: native
parameters: parameters:
- {id: host, label: Host name, type: string, default: localhost, pattern: "[a-zA-Z0-9.-]+"} - {id: host, label: Host name, type: string, default: localhost, pattern: "[a-zA-Z0-9.-]+"}

View file

@ -5,7 +5,6 @@ id: rtut.showcase-lighthouse-v2
title: "Showcase: a night at the lighthouse (v0.2)" title: "Showcase: a night at the lighthouse (v0.2)"
owner: runbook-tutorials owner: runbook-tutorials
outcome: You have played a short interactive story that touches every feature of the runbook engine, using only harmless echo, printf and a scratch file under /tmp/rtut-. outcome: You have played a short interactive story that touches every feature of the runbook engine, using only harmless echo, printf and a scratch file under /tmp/rtut-.
exercise_status: unexercised
engine: native engine: native
confirm_phrase: I understand this cannot be undone confirm_phrase: I understand this cannot be undone

View file

@ -3,7 +3,6 @@ id: rtut.showcase-lighthouse
title: "Showcase: a night at the lighthouse" title: "Showcase: a night at the lighthouse"
owner: runbook-tutorials owner: runbook-tutorials
outcome: You have played a short interactive story that touches every feature of the runbook engine, using only harmless echo, printf and a scratch file. Nothing outside /tmp/rtut-* is touched. outcome: You have played a short interactive story that touches every feature of the runbook engine, using only harmless echo, printf and a scratch file. Nothing outside /tmp/rtut-* is touched.
exercise_status: unexercised
engine: native engine: native
parameters: parameters:
- {id: keeper, label: Your keeper's name, type: string, default: Ada, pattern: "[A-Za-z]+", help: One word. Used in the story.} - {id: keeper, label: Your keeper's name, type: string, default: Ada, pattern: "[A-Za-z]+", help: One word. Used in the story.}

View file

@ -8,7 +8,6 @@
"id", "id",
"title", "title",
"owner", "owner",
"exercise_status",
"engine" "engine"
], ],
"additionalProperties": false, "additionalProperties": false,
@ -33,7 +32,8 @@
}, },
"exercise_status": { "exercise_status": {
"type": "string", "type": "string",
"pattern": "^(unexercised|exercised [0-9]{4}-[0-9]{2}-[0-9]{2} by \\S+)$" "deprecated": true,
"description": "Deprecated and ignored. Maturity is derived from run history (docs/maturity-and-projects.md); old packs that still carry this field keep loading."
}, },
"engine": { "engine": {
"enum": [ "enum": [

View file

@ -3,96 +3,292 @@
"$id": "https://runbook-tutorials.invalid/schema/runbook-pack/v0.1", "$id": "https://runbook-tutorials.invalid/schema/runbook-pack/v0.1",
"title": "Runbook pack manifest", "title": "Runbook pack manifest",
"type": "object", "type": "object",
"required": ["spec", "id", "title", "owner", "exercise_status", "engine"], "required": [
"spec",
"id",
"title",
"owner",
"engine"
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"spec": {"const": "runbook-pack/v0.1"}, "spec": {
"id": {"type": "string", "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$"}, "const": "runbook-pack/v0.1"
"title": {"type": "string", "minLength": 3}, },
"owner": {"type": "string", "minLength": 1, "description": "Repository that owns the procedure"}, "id": {
"outcome": {"type": "string"}, "type": "string",
"pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$"
},
"title": {
"type": "string",
"minLength": 3
},
"owner": {
"type": "string",
"minLength": 1,
"description": "Repository that owns the procedure"
},
"outcome": {
"type": "string"
},
"exercise_status": { "exercise_status": {
"type": "string", "type": "string",
"pattern": "^(unexercised|exercised [0-9]{4}-[0-9]{2}-[0-9]{2} by \\S+)$" "deprecated": true,
"description": "Deprecated and ignored. Maturity is derived from run history (docs/maturity-and-projects.md); old packs that still carry this field keep loading."
},
"engine": {
"enum": [
"native",
"legacy-command"
]
}, },
"engine": {"enum": ["native", "legacy-command"]},
"legacy": { "legacy": {
"type": "object", "type": "object",
"required": ["command"], "required": [
"command"
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"command": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "command": {
"cwd": {"type": "string"}, "type": "array",
"url": {"type": "string"}, "items": {
"note": {"type": "string"} "type": "string"
},
"minItems": 1
},
"cwd": {
"type": "string"
},
"url": {
"type": "string"
},
"note": {
"type": "string"
}
} }
}, },
"parameters": {"type": "array", "items": {"$ref": "#/$defs/parameter"}}, "parameters": {
"prerequisites": {"type": "array", "items": {"$ref": "#/$defs/prerequisite"}}, "type": "array",
"steps": {"type": "array", "items": {"$ref": "#/$defs/step"}}, "items": {
"threat_checks": {"type": "array", "items": {"type": "string"}}, "$ref": "#/$defs/parameter"
"ownership": {"type": "array", "items": {"$ref": "#/$defs/ownership"}} }
},
"prerequisites": {
"type": "array",
"items": {
"$ref": "#/$defs/prerequisite"
}
},
"steps": {
"type": "array",
"items": {
"$ref": "#/$defs/step"
}
},
"threat_checks": {
"type": "array",
"items": {
"type": "string"
}
},
"ownership": {
"type": "array",
"items": {
"$ref": "#/$defs/ownership"
}
}
}, },
"allOf": [ "allOf": [
{"if": {"properties": {"engine": {"const": "native"}}}, {
"then": {"required": ["steps", "threat_checks"]}}, "if": {
{"if": {"properties": {"engine": {"const": "legacy-command"}}}, "properties": {
"then": {"required": ["legacy"]}} "engine": {
"const": "native"
}
}
},
"then": {
"required": [
"steps",
"threat_checks"
]
}
},
{
"if": {
"properties": {
"engine": {
"const": "legacy-command"
}
}
},
"then": {
"required": [
"legacy"
]
}
}
], ],
"$defs": { "$defs": {
"parameter": { "parameter": {
"type": "object", "type": "object",
"required": ["id", "label", "type"], "required": [
"id",
"label",
"type"
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, "id": {
"label": {"type": "string"}, "type": "string",
"type": {"enum": ["string", "text", "enum", "path", "bool", "int"]}, "pattern": "^[a-z][a-z0-9_]*$"
"values": {"type": "array", "items": {"type": "string"}}, },
"default": {"type": ["string", "integer", "boolean"]}, "label": {
"pattern": {"type": "string"}, "type": "string"
"help": {"type": "string"}, },
"secret": {"const": false, "description": "Parameters are never secret. Secrets are entered by the operator into the tool that owns them, never into the engine."} "type": {
"enum": [
"string",
"text",
"enum",
"path",
"bool",
"int"
]
},
"values": {
"type": "array",
"items": {
"type": "string"
}
},
"default": {
"type": [
"string",
"integer",
"boolean"
]
},
"pattern": {
"type": "string"
},
"help": {
"type": "string"
},
"secret": {
"const": false,
"description": "Parameters are never secret. Secrets are entered by the operator into the tool that owns them, never into the engine."
}
} }
}, },
"prerequisite": { "prerequisite": {
"type": "object", "type": "object",
"required": ["text", "owner"], "required": [
"text",
"owner"
],
"additionalProperties": false, "additionalProperties": false,
"properties": {"text": {"type": "string"}, "owner": {"type": "string"}} "properties": {
"text": {
"type": "string"
},
"owner": {
"type": "string"
}
}
}, },
"check": { "check": {
"type": "object", "type": "object",
"required": ["done_when"], "required": [
"done_when"
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"done_when": {"type": "string", "minLength": 3}, "done_when": {
"command": {"type": "string"}, "type": "string",
"expect": {"enum": ["manual", "exit-0", "output-contains"]}, "minLength": 3
"contains": {"type": "string"} },
"command": {
"type": "string"
},
"expect": {
"enum": [
"manual",
"exit-0",
"output-contains"
]
},
"contains": {
"type": "string"
}
} }
}, },
"step": { "step": {
"type": "object", "type": "object",
"required": ["id", "title", "owner", "verify"], "required": [
"id",
"title",
"owner",
"verify"
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"id": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, "id": {
"title": {"type": "string"}, "type": "string",
"owner": {"type": "string"}, "pattern": "^[a-z][a-z0-9-]*$"
"description": {"type": "string"}, },
"command": {"type": "string", "description": "Template; {{param}} placeholders only"}, "title": {
"risk": {"enum": ["read-only", "changes-state", "attended", "destructive"], "default": "read-only"}, "type": "string"
"verify": {"$ref": "#/$defs/check"}, },
"rollback": {"type": "string"}, "owner": {
"evidence": {"type": "array", "items": {"type": "string"}, "description": "Names of non-secret facts recorded when the step completes"} "type": "string"
},
"description": {
"type": "string"
},
"command": {
"type": "string",
"description": "Template; {{param}} placeholders only"
},
"risk": {
"enum": [
"read-only",
"changes-state",
"attended",
"destructive"
],
"default": "read-only"
},
"verify": {
"$ref": "#/$defs/check"
},
"rollback": {
"type": "string"
},
"evidence": {
"type": "array",
"items": {
"type": "string"
},
"description": "Names of non-secret facts recorded when the step completes"
}
} }
}, },
"ownership": { "ownership": {
"type": "object", "type": "object",
"required": ["concern", "owner"], "required": [
"concern",
"owner"
],
"additionalProperties": false, "additionalProperties": false,
"properties": {"concern": {"type": "string"}, "owner": {"type": "string"}} "properties": {
"concern": {
"type": "string"
},
"owner": {
"type": "string"
}
}
} }
} }
} }

99
tests/test_cli.py Normal file
View file

@ -0,0 +1,99 @@
"""The command line, end to end. Each command runs through main() against a throwaway repository."""
import shutil
import sys
from pathlib import Path
import pytest
import yaml
from rtut.__main__ import main
ROOT = Path(__file__).resolve().parent.parent
@pytest.fixture
def repo(tmp_path):
for name, src in (("v1", "showcase-lighthouse"), ("v2", "showcase-lighthouse-v2")):
d = tmp_path / "runbooks" / name; d.mkdir(parents=True)
shutil.copy(ROOT / f"runbooks/{src}/pack.yaml", d / "pack.yaml")
(tmp_path / "attribute-sets").mkdir()
shutil.copy(ROOT / "attribute-sets/harbour-defaults.yaml", tmp_path / "attribute-sets/harbour-defaults.yaml")
(tmp_path / "project-templates").mkdir()
shutil.copy(ROOT / "project-templates/lighthouse-watch.yaml", tmp_path / "project-templates/lighthouse-watch.yaml")
return tmp_path
def run(capsys, *argv):
code = main([str(a) for a in argv])
out = capsys.readouterr()
return code, out.out, out.err
def test_validate_and_list_show_maturity_not_exercise_status(repo, capsys):
code, out, _ = run(capsys, "validate", repo)
assert code == 0 and out.count("ok ") == 2
code, out, _ = run(capsys, "list", repo)
assert code == 0 and "rtut.showcase-lighthouse\tnative\tdraft\t" in out and "exercise" not in out
code, out, _ = run(capsys, "show", repo / "runbooks/v1/pack.yaml")
assert code == 0 and "[draft]" in out and "engine=native" in out
def test_render_and_score_and_maturity(repo, capsys):
code, out, _ = run(capsys, "render", repo / "runbooks/v1/pack.yaml", "meet-the-keeper", "-p", "keeper=Bea")
assert code == 0 and out.strip().startswith('echo "Bea climbs the stairs')
code, out, _ = run(capsys, "score", repo)
assert code == 0 and "rtut.showcase-lighthouse\ttried 0" in out and out.rstrip().endswith("[draft]")
code, out, _ = run(capsys, "maturity", repo / "runbooks/v1/pack.yaml")
assert code == 0 and out.startswith("rtut.showcase-lighthouse: draft") and "reviewed: False" in out
def test_review_then_maturity(repo, capsys):
pack = repo / "runbooks/v1/pack.yaml"
assert run(capsys, "review", pack, "--by", "alice", "--note", "read it")[0] == 0
code, out, _ = run(capsys, "maturity", pack)
assert "rtut.showcase-lighthouse: reviewed" in out and "reviewed: True" in out
code, _, err = run(capsys, "review", pack, "--by", "two words")
assert code == 1 and "one word" in err
def test_run_commands_walk_a_v01_pack(repo, capsys):
pack = repo / "runbooks/v1/pack.yaml"
code, rid, _ = run(capsys, "run", "new", pack, "-o", "bernd", "-p", "logdir=/tmp/rtut-cli-x")
rid = rid.strip()
assert code == 0 and rid
assert run(capsys, "run", "start", pack, rid, "read-the-sign")[0] == 0
assert run(capsys, "run", "verify", pack, rid, "read-the-sign", "--pass")[0] == 0
assert run(capsys, "run", "done", pack, rid, "read-the-sign")[0] == 0
code, out, _ = run(capsys, "run", "status", pack, rid)
assert "done read-the-sign" in out and "todo meet-the-keeper" in out
code, out, _ = run(capsys, "run", "finalize", pack, rid, "--outcome", "abandoned", "--note", "cli test")
assert code == 0 and out.strip() == "abandoned"
code, _, err = run(capsys, "run", "start", pack, rid, "meet-the-keeper")
assert code == 1 and "finished" in err
assert run(capsys, "run", "feedback", pack, rid, "--rating", "poor", "--note", "x")[0] == 1 # closed runs are locked
def test_the_retired_finish_action_is_gone(repo):
with pytest.raises(SystemExit):
main(["run", "finish", str(repo / "runbooks/v1/pack.yaml"), "x"])
def test_project_commands(repo, capsys):
code, out, _ = run(capsys, "project", "list", repo)
assert code == 0 and "lighthouse-watch\ttemplate" in out
code, out, _ = run(capsys, "project", "new", repo, "lighthouse-watch", "demo", "-e", "rehearsal", "--title", "Demo watch",
"--description", "A demo", "-s", "keeper=Ada", "-s", "logdir=/tmp/rtut-cli-p")
assert code == 0 and out.strip().endswith("projects/demo/project.yaml")
made = yaml.safe_load((repo / "projects/demo/project.yaml").read_text())
assert made["title"] == "Demo watch" and made["description"] == "A demo" and made["template"] == "lighthouse-watch"
# the template's example members need packs this fixture does not have, so validation reports it rather than crashing
code, out, _ = run(capsys, "project", "validate", repo, "demo")
assert code == 1 and "FAIL demo" in out and "rtut.example-hello not found" in out
code, _, err = run(capsys, "project", "status", repo, "nope")
assert code == 1 and "no project nope" in err
def test_ui_and_launch_arguments_parse(repo):
from rtut.__main__ import main as m
with pytest.raises(SystemExit):
m(["ui", "--port", "not-a-port"])

View file

@ -17,7 +17,7 @@ def make_repo(tmp_path, command, url, cwd="."):
d.mkdir(parents=True) d.mkdir(parents=True)
(d / "pack.yaml").write_text(yaml.safe_dump({ (d / "pack.yaml").write_text(yaml.safe_dump({
"spec": "runbook-pack/v0.1", "id": "t.old", "title": "Old console", "owner": "t", "spec": "runbook-pack/v0.1", "id": "t.old", "title": "Old console", "owner": "t",
"exercise_status": "unexercised", "engine": "legacy-command", "engine": "legacy-command",
"legacy": {"command": command, "cwd": cwd, "url": url}})) "legacy": {"command": command, "cwd": cwd, "url": url}}))
return P.load(d / "pack.yaml") return P.load(d / "pack.yaml")

View file

@ -33,8 +33,10 @@ def test_digest_is_stable_ignores_exercise_status_and_reviews_but_changes_with_c
d1 = P.digest(pk) d1 = P.digest(pk)
assert d1 == P.digest(pk) assert d1 == P.digest(pk)
text = pk.path.read_text() text = pk.path.read_text()
pk.path.write_text(text.replace("exercise_status: unexercised", "exercise_status: exercised 2026-09-29 by x")) assert "engine: native\n" in text
assert P.digest(pk) == d1 # recording an exercise is not a new revision pk.path.write_text(text.replace("engine: native\n", "exercise_status: exercised 2026-09-29 by x\nengine: native\n", 1)) # as an old pack carries it
assert P.digest(pk) == d1 # the deprecated field is ignored: dropping it is not a new revision
pk.path.write_text(text)
M.add_review(pk, "alice", "read it") M.add_review(pk, "alice", "read it")
assert P.digest(pk) == d1 # neither is a review assert P.digest(pk) == d1 # neither is a review
pk.path.write_text(text + "\n# changed\n") pk.path.write_text(text + "\n# changed\n")

View file

@ -29,8 +29,6 @@ def mut(fn):
@pytest.mark.parametrize("fn,frag", [ @pytest.mark.parametrize("fn,frag", [
(lambda d: d.pop("exercise_status"), "exercise_status"),
(lambda d: d.update(exercise_status="done"), "exercise_status"),
(lambda d: d["parameters"][0].update(secret=True), "secret"), (lambda d: d["parameters"][0].update(secret=True), "secret"),
(lambda d: d["steps"][0].pop("verify"), "verify"), (lambda d: d["steps"][0].pop("verify"), "verify"),
(lambda d: d["steps"][0].update(command="x {{nope}}"), "undefined placeholder"), (lambda d: d["steps"][0].update(command="x {{nope}}"), "undefined placeholder"),
@ -49,7 +47,7 @@ def test_invalid(tmp_path, fn, frag):
def test_legacy_requires_command(tmp_path): def test_legacy_requires_command(tmp_path):
d = {"spec": "runbook-pack/v0.1", "id": "x.y", "title": "Legacy x", "owner": "o", d = {"spec": "runbook-pack/v0.1", "id": "x.y", "title": "Legacy x", "owner": "o",
"exercise_status": "unexercised", "engine": "legacy-command"} "engine": "legacy-command"}
assert not check(tmp_path, d).ok assert not check(tmp_path, d).ok
d["legacy"] = {"command": ["true"]} d["legacy"] = {"command": ["true"]}
assert check(tmp_path, d).ok assert check(tmp_path, d).ok
@ -74,3 +72,13 @@ def test_text_parameter_allows_spaces_but_not_metacharacters():
with pytest.raises(ValueError): with pytest.raises(ValueError):
P.render(d["steps"][0]["command"], {"host": "two words"}, pk) P.render(d["steps"][0]["command"], {"host": "two words"}, pk)
def test_the_deprecated_exercise_status_is_accepted_and_ignored_so_old_packs_still_load(tmp_path):
d = copy.deepcopy(GOOD)
d["exercise_status"] = "exercised 2026-01-01 by someone"
assert check(tmp_path, d).ok
d["exercise_status"] = "whatever text" # deprecated: not validated, not used
assert check(tmp_path, d).ok
d.pop("exercise_status")
assert check(tmp_path, d).ok # and it is not required any more

View file

@ -19,7 +19,7 @@ def v2_pack(pid, extra_step=None):
{"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}] {"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}]
if extra_step: if extra_step:
steps.append(extra_step) steps.append(extra_step)
return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "exercise_status": "unexercised", "engine": "native", return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "engine": "native",
"framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}], "framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}],
"datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}], "datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}],
"stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]} "stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]}

View file

@ -51,9 +51,7 @@ def test_full_run_and_exercise(pk):
pytest.skip("ping unavailable in this environment") pytest.skip("ping unavailable in this environment")
R.complete(r, "ping") R.complete(r, "ping")
assert r.complete assert r.complete
old, new = R.exercise_update(r, date(2026, 9, 29)) assert pk.path.read_text() == P.load(pk.path).path.read_text() # the engine never writes a pack
assert "exercise_status: exercised 2026-09-29 by bernd" in new and old != new
assert pk.path.read_text() == old # never written by the engine
def test_order_fail_retry_skip(pk): def test_order_fail_retry_skip(pk):
@ -69,8 +67,8 @@ def test_order_fail_retry_skip(pk):
R.fail(r, "ping", "-----BEGIN RSA PRIVATE KEY-----") R.fail(r, "ping", "-----BEGIN RSA PRIVATE KEY-----")
r2 = R.create(pk, "bernd") r2 = R.create(pk, "bernd")
R.skip(r2, "ping", "not needed") R.skip(r2, "ping", "not needed")
with pytest.raises(R.RunError): with pytest.raises(R.RunError, match="succeeds only when every step is done"):
R.exercise_update(r2) # skips never exercise a pack R.finalize(r2, "succeeded") # a skipped step never counts as done
def test_manual_verification_needs_answer(pk): def test_manual_verification_needs_answer(pk):

View file

@ -60,6 +60,14 @@ def pass_gate(r, gid):
R.gate_check(r, gid, i, confirm=True, manual_ok=True) R.gate_check(r, gid, i, confirm=True, manual_ok=True)
def counts_towards_maturity(r) -> bool:
"""Would this run be clean maturity evidence? (succeeded, no deviation, with an environment)"""
from rtut import maturity as M
r.data["outcome"] = "succeeded"
r.data["context"] = {"project": "p", "environment": "rehearsal"}
return M._evidence(r.pack, r.data.get("pack_digest"), [r])["clean_runs"] == 1
# ---- datapoints ----------------------------------------------------------------------------------- # ---- datapoints -----------------------------------------------------------------------------------
def test_create_sets_datapoint_states(tmp_path): def test_create_sets_datapoint_states(tmp_path):
@ -165,15 +173,11 @@ def test_waiver_only_where_declared_with_reason_and_recorded(tmp_path):
R.waive(r, "night-on-record", "try") R.waive(r, "night-on-record", "try")
def test_deviations_block_exercise_but_not_finalize(tmp_path): def test_deviations_keep_a_run_out_of_maturity(tmp_path):
r = new(tmp_path) r = new(tmp_path)
R.start(r, "warn", alt="sound-horn", override_reason="probe", acknowledge_risk=True) R.start(r, "warn", alt="sound-horn", override_reason="probe", acknowledge_risk=True)
r.data["steps"]["warn"]["state"] = "failed" assert r.data["deviations"] and not counts_towards_maturity(r)
assert r.data["deviations"] assert counts_towards_maturity(new(tmp_path)) # the same without a deviation would count
with pytest.raises(R1.RunError, match="deviations"):
for k in r.data["steps"]:
r.data["steps"][k]["state"] = "done"
R1.exercise_update(r)
# ---- confirmation styles ------------------------------------------------------------------------------ # ---- confirmation styles ------------------------------------------------------------------------------
@ -264,10 +268,7 @@ def test_auto_confirm_push_through_records_deviation_and_moves_on(tmp_path):
assert dev["kind"] == "push-through" and dev["mode"] == "auto-confirm" and dev["risk_acknowledged"] is False assert dev["kind"] == "push-through" and dev["mode"] == "auto-confirm" and dev["risk_acknowledged"] is False
nxt = R.auto_confirm_tick(r, late) # continues to the manual-only step nxt = R.auto_confirm_tick(r, late) # continues to the manual-only step
assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator" assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator"
with pytest.raises(R1.RunError, match="deviations"): assert not counts_towards_maturity(r) # a pushed-through run is not evidence
for k in r.data["steps"]:
r.data["steps"][k]["state"] = "done"
R1.exercise_update(r)
def test_auto_confirm_gate_push_through_and_stop(tmp_path): def test_auto_confirm_gate_push_through_and_stop(tmp_path):
@ -460,8 +461,4 @@ def test_a_run_that_changed_a_used_value_never_counts_towards_maturity(tmp_path)
r = new(tmp_path) r = new(tmp_path)
do_step(r, "read-the-sky") do_step(r, "read-the-sky")
R.set_datapoint(r, "weather", "clear", override_reason="changed my mind", acknowledge_risk=True) R.set_datapoint(r, "weather", "clear", override_reason="changed my mind", acknowledge_risk=True)
assert r.data["deviations"] assert r.data["deviations"] and not counts_towards_maturity(r)
with pytest.raises(R1.RunError, match="deviations"):
for k in r.data["steps"]:
r.data["steps"][k]["state"] = "done"
R1.exercise_update(r)

View file

@ -64,17 +64,6 @@ def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
assert c["feedback"]["good"] == 1 assert c["feedback"]["good"] == 1
def test_finalizing_success_can_record_exercise_status(pk):
r = R.create(pk, "bernd"); play_all(r)
with pytest.raises(R.RunError):
R.apply_exercise(r) # not finalized yet
R.finalize(r, "succeeded")
status = R.apply_exercise(r)
assert status.startswith("exercised ") and status.endswith(" by bernd")
reloaded = P.load(pk.path)
assert reloaded.ok and reloaded.data["exercise_status"] == status
def test_capture_documentation_and_limits(pk): def test_capture_documentation_and_limits(pk):
r = R.create(pk, "bernd") r = R.create(pk, "bernd")
with pytest.raises(R.RunError, match="start the step"): with pytest.raises(R.RunError, match="start the step"):
@ -166,7 +155,7 @@ def test_closing_records_feedback_with_the_outcome_and_validates_before_changing
assert R.load(pk, r.data["run_id"]).data["feedback"][0]["rating"] == "poor" assert R.load(pk, r.data["run_id"]).data["feedback"][0]["rating"] == "poor"
def test_a_closed_record_cannot_be_saved_with_changes_but_the_exercise_marker_is_allowed(pk): def test_a_closed_record_cannot_be_saved_with_changes(pk):
r = R.create(pk, "bernd"); R.finalize(r, "abandoned") r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
r2 = R.load(pk, r.data["run_id"]) r2 = R.load(pk, r.data["run_id"])
r2.data["steps"]["read-the-sign"]["state"] = "done" r2.data["steps"]["read-the-sign"]["state"] = "done"
@ -177,10 +166,7 @@ def test_a_closed_record_cannot_be_saved_with_changes_but_the_exercise_marker_is
with pytest.raises(R.RunError, match="operator"): with pytest.raises(R.RunError, match="operator"):
r3.save() r3.save()
assert R.load(pk, r.data["run_id"]).data["operator"] == "bernd" # nothing reached the disk assert R.load(pk, r.data["run_id"]).data["operator"] == "bernd" # nothing reached the disk
r4 = R.load(pk, r.data["run_id"]) assert R.seal_state(R.load(pk, r.data["run_id"])) == "sealed" # and the seal still holds
r4.data["exercise_recorded"] = "x"
r4.save(allow=("exercise_recorded",)) # the one permitted later update
assert R.load(pk, r.data["run_id"]).data["exercise_recorded"] == "x" and R.seal_state(R.load(pk, r.data["run_id"])) == "sealed"
def test_closing_seals_the_file_read_only_and_tampering_is_detected(pk): def test_closing_seals_the_file_read_only_and_tampering_is_detected(pk):

View file

@ -35,8 +35,8 @@ def test_scripted_playthrough(pk):
R.complete(r, sid, {n: "1" for n in s.get("evidence", [])}) R.complete(r, sid, {n: "1" for n in s.get("evidence", [])})
assert r.complete assert r.complete
assert not Path(r.data["params"]["logdir"]).exists() assert not Path(r.data["params"]["logdir"]).exists()
old, new = R.exercise_update(r) R.finalize(r, "succeeded") # every step done: the run may succeed
assert "exercised" in new assert r.data["outcome"] == "succeeded"
def test_bad_logdir_rejected(pk): def test_bad_logdir_rejected(pk):

View file

@ -245,17 +245,11 @@ def test_score_runs_list_and_finalize_flow(srv):
assert req(srv, "POST", base + "/finalize", {"_t": t, "outcome": "succeeded", "note": "smooth"})[0] == 303 assert req(srv, "POST", base + "/finalize", {"_t": t, "outcome": "succeeded", "note": "smooth"})[0] == 303
_, _, body = req(srv, "GET", base) _, _, body = req(srv, "GET", base)
assert "Run finished: succeeded" in body and "smooth" in body assert "Run finished: succeeded" in body and "smooth" in body
assert "Record pack as exercised" in body and "would set it to" in body # explicit, with the diff in words assert "Record pack as exercised" not in body and "would set it to" not in body # retired: maturity is derived from runs
assert "Start this step" not in body # read-only now assert "Start this step" not in body # read-only now
_, _, body = req(srv, "GET", "/") _, _, body = req(srv, "GET", "/")
assert "succeeded 1" in body assert "succeeded 1" in body
# exercised status is only written on the explicit click assert req(srv, "POST", base + "/exercise", {"_t": t})[0] == 400 # the route is gone
pack_file = srv.app.packs[i].path
assert "exercise_status: unexercised" in pack_file.read_text()
assert req(srv, "POST", base + "/exercise", {"_t": t})[0] == 303
assert "exercise_status: exercised" in pack_file.read_text()
_, _, body = req(srv, "GET", base)
assert "Pack recorded as" in body and "Record pack as exercised" not in body
def test_abandon_and_actions_refused_after_finish(srv): def test_abandon_and_actions_refused_after_finish(srv):
@ -264,7 +258,6 @@ def test_abandon_and_actions_refused_after_finish(srv):
assert req(srv, "POST", base + "/finalize", {"_t": t, "outcome": "abandoned"})[0] == 303 assert req(srv, "POST", base + "/finalize", {"_t": t, "outcome": "abandoned"})[0] == 303
st, _, body = req(srv, "POST", base + "/read-the-sign/start", {"_t": t}) st, _, body = req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
assert st == 400 and "finished" in body assert st == 400 and "finished" in body
assert req(srv, "POST", base + "/exercise", {"_t": t})[0] == 400 # only a succeeded run may exercise the pack
def test_capture_and_pasted_output_check(srv): def test_capture_and_pasted_output_check(srv):

View file

@ -19,7 +19,7 @@ def v2_pack(pid, manual_only_last=False):
if manual_only_last: if manual_only_last:
steps.append({"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only", steps.append({"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only",
"verify": {"done_when": "you did it", "expect": "manual"}}) "verify": {"done_when": "you did it", "expect": "manual"}})
return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "exercise_status": "unexercised", "engine": "native", return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "engine": "native",
"framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}], "framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}],
"datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}], "datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}],
"stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]} "stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]}

View file

@ -106,7 +106,7 @@ def test_three_levels_allowed():
def test_legacy_v02_needs_only_schema(): def test_legacy_v02_needs_only_schema():
d = {"spec": "runbook-pack/v0.2", "id": "x.y", "title": "Legacy x", "owner": "o", d = {"spec": "runbook-pack/v0.2", "id": "x.y", "title": "Legacy x", "owner": "o",
"exercise_status": "unexercised", "engine": "legacy-command", "legacy": {"command": ["true"]}} "engine": "legacy-command", "legacy": {"command": ["true"]}}
assert v2.validate(d) == [] assert v2.validate(d) == []
@ -133,3 +133,10 @@ def test_idempotency_is_declared_validated_and_summarized():
assert flow.pack_idempotency(d) == "unknown" # one undeclared step makes the whole unknown assert flow.pack_idempotency(d) == "unknown" # one undeclared step makes the whole unknown
assert errs(lambda x: step(x, "warn").update(idempotent="sometimes")) assert errs(lambda x: step(x, "warn").update(idempotent="sometimes"))
assert errs(lambda x: step(x, "warn").update(idempotent=True)) # a YAML "yes" is refused, not silently accepted assert errs(lambda x: step(x, "warn").update(idempotent=True)) # a YAML "yes" is refused, not silently accepted
def test_v02_accepts_the_deprecated_exercise_status_and_does_not_require_it():
d = copy.deepcopy(GOOD)
assert "exercise_status" not in d and v2.validate(d) == []
d["exercise_status"] = "exercised 2026-01-01 by someone"
assert v2.validate(d) == []

View file

@ -27,13 +27,14 @@ backlog and do only a small JSON export.
```task ```task
id: RBT-WP-0009-T01 id: RBT-WP-0009-T01
status: todo status: done
priority: high priority: high
``` ```
`exercise_status` mixed three things (RBT-WP-0008). Maturity, derived from run history, replaces it. Remove the "Record pack as Done 2026-09-29. `exercise_status` mixed three things (RBT-WP-0008). Maturity, derived from run history, replaces it. Remove the "Record pack as
exercised" click and `apply_exercise` / `exercise_update`; drop the field from the shipped packs (both repositories) and from the UI exercised" click and `apply_exercise` / `exercise_update`; drop the field from the shipped packs (both repositories) and from the UI
headers; make it optional and deprecated in both schemas so old packs still load; show maturity where the status was shown. headers; make it optional and deprecated in both schemas so old packs still load; show maturity where the status was shown.
Also found and fixed while doing it: the CLI had no tests, and `rtut list` crashed from a shadowed import; `tests/test_cli.py` now covers it.
## Packaging, checks and CI ## Packaging, checks and CI