diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml
new file mode 100644
index 0000000..31e0cb6
--- /dev/null
+++ b/.forgejo/workflows/tests.yaml
@@ -0,0 +1,36 @@
+# Runs the test suite and validates every runbook pack, attribute set, project and template.
+# Follows the fleet pattern of work-records.yaml: no actions/checkout (the runner has no git), the repo comes
+# from the Forgejo archive endpoint. The engine needs Python 3.12 (pyproject: requires-python), so the job runs in a
+# python:3.12 container instead of the node:20-bookworm default, which ships an older Python.
+# NOT YET RUN on a real runner: verify the first execution and adjust the container or label if the runner refuses it.
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ container:
+ image: python:3.12-slim
+ steps:
+ - name: Fetch the repository, install, test and check
+ run: |
+ set -eu
+ apt-get update -qq >/dev/null
+ apt-get install -y -qq wget >/dev/null
+ WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
+ REF="${GITHUB_SHA:-main}"; SHORT="${REF:0:7}"
+ BASE="https://forgejo.coulomb.social"
+ mkdir -p "$WORK/repo"
+ # Forgejo archive endpoint accepts short SHA; full SHA can hang.
+ wget -qO "$WORK/repo.tar.gz" "${BASE}/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz"
+ tar xzf "$WORK/repo.tar.gz" -C "$WORK/repo" --strip-components=1
+ cd "$WORK/repo"
+ python -m pip install -q -e ".[test]"
+ python -m pytest -q
+ python -m rtut check .
diff --git a/INTENT.md b/INTENT.md
index 36b5d06..f837513 100644
--- a/INTENT.md
+++ b/INTENT.md
@@ -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
renders commands with the operator's values and never asks for secrets.
- **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
- unexercised, following the attended-procedure standard.
+ passed and its outcome is recorded. How far a runbook can be trusted (its
+ maturity) is earned from clean runs in named environments, not declared.
- **Never a secret vault.** The engine does not collect, store, log or
transmit secret values. It shows the command; the operator runs it.
- **Guide, not executor by default.** Steps are run by the operator. Any
diff --git a/Makefile b/Makefile
index c8da042..f96dfd5 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,15 @@
-.PHONY: help test validate fix-consistency
+.PHONY: help install test check validate ui fix-consistency
help: ## List targets
@grep -E '^[a-z-]+:.*##' Makefile | sed 's/:.*##/ -/'
+install: ## Install the engine and its test dependencies into the current environment (editable)
+ python3 -m pip install -e ".[test]"
test: ## Run the test suite
- python3 -m pytest tests
+ python3 -m pytest
+check: ## Validate every pack, attribute set, project and template in this repository
+ python3 -m rtut check .
validate: ## Validate runbook packs under runbooks/
python3 -m rtut validate runbooks
+ui: ## Serve the local UI for this repository on 127.0.0.1:8877
+ python3 -m rtut ui .
fix-consistency: ## Sync workplans to State Hub: make fix-consistency REPO=runbook-tutorials
statehub fix-consistency --repo $(REPO)
diff --git a/README.md b/README.md
index 6d0c37d..fcf4ea3 100644
--- a/README.md
+++ b/README.md
@@ -4,14 +4,26 @@ A user interface specialized to present runbook tutorials that with parameters
can guide through actual IT administration and operation tasks.
- **Intent:** `INTENT.md` · **Scope:** `SCOPE.md`
-- **Pack specification:** `docs/pack-spec.md` (v0.1, runnable) and `docs/pack-spec-v0.2.md` (v0.2: stages, gates, datapoints, actors, modes; validates, not yet runnable). Schemas in `schema/`. UX findings: `docs/ux-review-2026-09-29.md`
+- **Pack specification:** `docs/pack-spec.md` (v0.1, runnable) and `docs/pack-spec-v0.2.md` (v0.2: stages, gates, datapoints, actors, modes; validates, not yet runnable). Schemas in `rtut/schema/`. UX findings: `docs/ux-review-2026-09-29.md`
- **Packs live where the procedure lives:** `runbooks//pack.yaml` in the owning repo.
Examples: `net-kingdom/runbooks/`, and here `runbooks/example-hello/` and
`runbooks/showcase-lighthouse/` (a harmless story to try the UI on).
+## Install
+
+Python 3.12 or newer.
+
+```bash
+make install # editable install with the test dependencies (pip install -e ".[test]")
+make check # validate every pack, attribute set, project and template
+make test # run the tests
+```
+
+A plain `pip install .` works too and provides the `rtut` command; the JSON schemas ship inside the package.
+
```bash
python3 -m rtut validate # validate every pack
-python3 -m rtut list # id, engine, exercise status, title
+python3 -m rtut list # id, engine, maturity, title
python3 -m rtut show # steps with owners and done-when
python3 -m rtut render -p name=value
python3 -m rtut launch # legacy-command packs: start the existing console
@@ -19,10 +31,8 @@ python3 -m rtut maturity # draft | reviewed | rehearsed | prove
python3 -m rtut review --by you # record a review of the current revision
python3 -m rtut project list|validate|status|attrs|new|run ... # projects, attribute sets, templates
python3 -m rtut ui [ ...] # 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 ... # the same run record from the terminal
+python3 -m rtut run new|start|verify|done|finalize ... # the same run record from the terminal
make test
```
-Status: specification, validator, legacy launch, run records with verification and
-exercise-status diff, and a first local web UI work. Terminal walk-through mode and
-the console migration are open (`workplans/`).
+Status: see `SCOPE.md`; the workplans are in `workplans/`.
diff --git a/SCOPE.md b/SCOPE.md
index a5802c7..65d2421 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -20,7 +20,7 @@ bootstrap console) working unchanged.
- Runbook pack specification, JSON Schema, and versioning rules
- Pack discovery, loading, validation and conformance tests
- 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
- Example and conformance packs
diff --git a/docs/console-mapping.md b/docs/console-mapping.md
new file mode 100644
index 0000000..413c5c2
--- /dev/null
+++ b/docs/console-mapping.md
@@ -0,0 +1,109 @@
+# Is v0.2 expressive enough? Mapping the security bootstrap console
+
+Status: analysis, 2026-09-29 (RBT-WP-0009-T05; closes RBT-WP-0005-T03).
+
+The plan for adopting `runbook-pack/v0.2` said: use the NetKingdom security bootstrap console
+(`net-kingdom/tools/security-bootstrap-console/`, 5,300 lines, invented in NET-WP-0016) as the first real test. If the model
+can express its stages, gates and runbooks without special cases, it is expressive enough. This is that test, done from the
+console's own definitions (read from its running `/api/status` and its source, against a throwaway metadata file).
+
+## Verdict
+
+**The structure maps; the behaviour does not fully.** v0.2 expresses the console's stages, gates, actors, dimensions, artefacts,
+evidence validators, typed approval and alternatives. It does not yet express five behaviours the console relies on: conditional
+applicability, re-evaluation of gates that can regress, outputs of one member feeding the next, local-only attributes, and
+gates satisfied by an automation path. So v0.2 is adequate for **procedures** (every runbook in the console, and the three
+NetKingdom packs) and **not yet proven adequate for a long-lived staged bootstrap** as a whole. Decision: the console stays a permanent
+legacy pack (RBT-WP-0005-T05); extracting its engine parts (T04) is moved to the backlog until the gaps below are closed.
+
+## What the console contains
+
+| Element | Count | Notes |
+| --- | --- | --- |
+| Stages | 6 | S1 Low-trust assembly, S2 King credential preparation, S3 OpenBao bootstrap, S4 Admin identity integration, S5 Cleanup and hardening, S6 Reopen under custody |
+| Top-level gates | 14 | statuses `done`, `blocked`, `human`, `automation` |
+| Sub-checklists | 11 + 4 + 4 | king-credential kit items, key-custody items, admin-identity integrations |
+| Section statuses | 9 | view-level completeness of the UI's own sections, not gates |
+| Roles | 6 | setup-operator, platform-root-custodian, identity-admin, openbao-ceremony-operator, recovery-custodian, future-quorum-custodian; each with a designated contact |
+| Subsystems, integrations | 6 + 6 | LLDAP, privacyIDEA, KeyCape, OpenBao, custodian age envelope, bootstrap bundle |
+| Runbooks | 7 | all `template` state; 23 copyable command blocks in total |
+| Artefacts | 12 | references only (a password-safe entry, an age key location, unseal shares held elsewhere); states nil, set, ok |
+| Approval | 1 | typed phrase `approve custody mode`, two approvable modes |
+
+## How the console works, which decides the mapping
+
+1. **A persistent fact document** (`.local/security-bootstrap.json`, flat keys such as `openbao_initialized`). It lives for weeks.
+2. **The stage is derived from the facts** (`derive_stage`: the most advanced fact wins), not from stepping through a list.
+3. **Gates are computed on every view**, from the facts and from live probes (files present, a Secret deployed in the cluster). A gate can go
+ back from done to blocked when the system changes.
+4. **Gate status is a small vocabulary**: `done`; `blocked` (a precondition is missing); `human` (ready, waiting for a person);
+ `automation` (satisfied by an unattended path, for example the SOPS-held init).
+5. **Applicability depends on choices**: the unseal custody model (sops-held, attended ceremony, auto-unseal) changes which init gate
+ applies, and the production profile forbids one of them.
+
+## The 14 gates
+
+| # | Gate | Stage (proposed) | v0.2 form | Verdict |
+| --- | --- | --- | --- | --- |
+| 1 | King credential kit | S2 | gate `datapoints_ok` over the 11 kit items (datapoints, confirmed by steps) | direct |
+| 2 | Custody strategy approval | S2 | irreversible step with `confirm_phrase: approve custody mode`; gate on the `custody_mode` datapoint | **caveat G4**: only some modes may approve live init; needs a value predicate, or an enum limited to the approvable modes |
+| 3 | OpenBao preflight | S3 | gate check `command`, exit 0 (`make ... openbao-status`) | direct |
+| 4 | OpenBao unseal custody model | S3 | enum datapoint `custody_model`; the production profile blocks one value | **caveat G4** (cross-datapoint rule) and G1 |
+| 5 | OpenBao init ceremony | S3 | step with alternatives (attended ceremony / automated init), recommended by `custody_model` | alternatives fit, but the automated path is executed by a script: **needs execution (G6)**, and refusing live init under some profiles is G1 |
+| 6 | OpenBao initial configuration | S3 | step, then gate on an evidence datapoint | direct |
+| 7 | KeyCape OpenBao client definition | S4 | gate check `command` probing the source | direct; **G3** (recheck) |
+| 8 | KeyCape OpenBao client deployed | S4 | gate check `command` probing the live Secret | direct; **G3** |
+| 9 | OpenBao OIDC auth | S4 | step plus evidence datapoint | direct |
+| 10 | OIDC admin login | S4 | manual check (a browser login through KeyCape and MFA), plus a claims check by command | direct |
+| 11 | Root-token disposition | S5 | enum datapoint limited to `revoked` and `offline-sealed`; gate `datapoints_ok` | direct |
+| 12 | Restore drill | S5 | a separate pack (7 command blocks, evidence validator); gate check runs the validator | **caveat**: needs a dependency between runbooks (`requires_packs`, RBT-WP-0006-T04), or the drill inlined |
+| 13 | Cleanup and rotation | S5 | gate with several manual checks | direct |
+| 14 | Platform reopen | S6 | gate check by the custodian actor, with an irreversible confirmation | direct |
+
+**Ten map directly, three with a caveat, one needs execution.** The console does not tie gates to stages explicitly; v0.2 does, which is
+the improvement asked for in the UX review (each gate belongs to one stage and names its quality goal). The sub-checklists map to
+datapoints and steps in the stage they belong to.
+
+## The rest of the model
+
+| Console | v0.2 | Fit |
+| --- | --- | --- |
+| Stages S1 to S6 | `stages`, completion derived from members | good; order by `requires` |
+| Roles with designated contacts | `actors`, plus a datapoint per contact carrying the `actor` dimension | good; see G5 for the contacts |
+| Subsystems | the `subsystem` dimension on datapoints | good; the data view already groups by it |
+| Artefacts (nil, set, ok) | `artefacts` with a location datapoint | good; artefact state is derived from the location datapoint |
+| The 7 runbooks | 7 native packs | good and independent of the gaps: each is a linear procedure with commands |
+| The 23 command blocks | step commands; the interactive `read -rs` prompts stay operator-run | good; nothing secret is ever entered into the engine |
+| Typed approval phrase | `confirm_phrase` on an irreversible step | good |
+| Evidence validators (`validate-*`) | gate or step checks by `command`, exit 0 | good |
+| Copyable command blocks, next action | the guided flow | the new UI does both |
+
+## The gaps
+
+These are candidates for a v0.3. None blocks closing the runbook-tutorials workplans, and none affects the procedures that exist today.
+
+- **G1 Conditional applicability.** Stages, steps and gates that apply only for some choices (`applies_when` on a datapoint value). `one_of`
+ chooses between alternatives inside a step; it cannot switch a whole gate on or off.
+- **G2 A longer horizon.** A run untouched for seven days counts as abandoned. A multi-week ceremony needs a per-pack (or per-run) horizon.
+- **G3 Re-evaluating a gate.** A passed gate is final in a run. The console's gates regress when the system changes. A gate needs a recheck,
+ and a passed gate that fails a recheck needs a state of its own.
+- **G4 Value predicates.** `datapoints_ok` only asks that a datapoint is `ok`. The console needs "this datapoint is one of these values" and
+ rules across two datapoints (`production` excludes `sops-held-automation`).
+- **G5 Local-only attributes.** The console keeps role contacts (email addresses) in an ignored `.local` file. Attribute sets and project
+ files are meant to be committed, so personal data would land in git. An attribute needs a `local` visibility that is never written into a
+ repository file.
+- **G6 Gates satisfied by an automation path.** The console's `automation` status means an unattended script did the work. Auto-confirm
+ only confirms; running the script is the opt-in execution level (RBT-WP-0006-T01).
+- **G7 Outputs feed later members.** A project run snapshots its attributes at the start. Facts established by one member (the custody
+ model chosen in S2, "OpenBao initialized" in S3) do not flow into the members after it. A staged bootstrap as a project needs that flow.
+
+## Consequences
+
+- **The seven runbooks and the three NetKingdom packs can be native v0.2 packs now.** The three NetKingdom packs are ported in
+ RBT-WP-0009-T06. The seven console runbooks are ready to port as a separate content task in net-kingdom; they need none of G1 to G7.
+- **The console stays.** It is a permanent legacy pack, launched unchanged. What it does well and v0.2 cannot yet do (live probes,
+ regression, a fact store that outlives a session, conditional gates) is exactly G1, G3, G5 and G7.
+- **The engine extraction (RBT-WP-0005-T04) waits for G1, G3 and G7.** Extracting the generic parts first would freeze the console's
+ workarounds into the engine.
+- **The console is a project, not a run.** The right long-term shape is a project whose members are runbook packs per stage, with facts
+ flowing between them (G7). That is a design task for a v0.3, not a migration.
diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md
index ee9f408..8ab7062 100644
--- a/docs/maturity-and-projects.md
+++ b/docs/maturity-and-projects.md
@@ -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
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
(for example `net-kingdom/projects//project.yaml`), next to `runbooks/`.
diff --git a/docs/pack-spec-v0.2.md b/docs/pack-spec-v0.2.md
index 58bb6b6..248f12a 100644
--- a/docs/pack-spec-v0.2.md
+++ b/docs/pack-spec-v0.2.md
@@ -2,7 +2,7 @@
Status: **adopted 2026-09-29** (Bernd's review answers are in the Decisions section).
Derived from `docs/ux-review-2026-09-29.md`. v0.1 stays valid; v0.2 is a superset.
-Machine-checkable form: `schema/runbook-pack-v0.2.schema.json` plus the semantic
+Machine-checkable form: `rtut/schema/runbook-pack-v0.2.schema.json` plus the semantic
checks in `rtut/v2.py`. The engine and UI do not run v0.2 packs yet (RBT-WP-0007).
## Principle
@@ -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`,
`unmet` (what was not satisfied), `risk_acknowledged`, `reason` (required for
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.
## 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
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)
diff --git a/docs/pack-spec.md b/docs/pack-spec.md
index a5017cf..9805317 100644
--- a/docs/pack-spec.md
+++ b/docs/pack-spec.md
@@ -2,7 +2,7 @@
A **runbook pack** is a directory `runbooks//` in the repository that
owns the procedure. It contains `pack.yaml`, validated by
-`schema/runbook-pack.schema.json`, plus optional supporting files. The engine
+`rtut/schema/runbook-pack.schema.json`, plus optional supporting files. The engine
discovers packs by scanning `runbooks/*/pack.yaml` under a repository root, or
under any root given on the command line.
@@ -14,7 +14,7 @@ under any root given on the command line.
| `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 |
| `owner` | yes | The repository that owns the procedure |
-| `exercise_status` | yes | `unexercised` or `exercised YYYY-MM-DD by ` |
+| `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` |
| `parameters` | no | Operator-supplied, non-secret values |
| `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 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
-operator has completed it, and the operator is recorded. The engine displays the
-status prominently and only the engine's evidence writer changes it.
+How far a pack can be trusted is earned, not declared: `draft`, `reviewed`, `rehearsed`, `proven`, or `stale`, derived from the
+runs of the pack at its current revision (`docs/maturity-and-projects.md`). The earlier `exercise_status` field is retired.
## Secrets
diff --git a/docs/run-spec.md b/docs/run-spec.md
index 6a44d6f..355133c 100644
--- a/docs/run-spec.md
+++ b/docs/run-spec.md
@@ -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.
- 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.
-- 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.
-- The engine never edits `pack.yaml`. `rtut run finish` shows the exercise-status
- diff; `--apply` writes it, and only for a run in which every step is done.
+- 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
+ (`rtut maturity`, `docs/maturity-and-projects.md`).
-CLI: `rtut run new|list|status|cmd|start|verify|done|fail|retry|skip|finish ...`
+CLI: `rtut run new|list|status|cmd|start|verify|done|fail|retry|skip|feedback|finalize|capture ...`
-## 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
an `outcome`:
@@ -41,11 +41,10 @@ its run records: tried, succeeded, failed, abandoned, stalled (unfinished), plus
(`rtut score `; 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.
-Finalizing never edits the pack by itself. For a `succeeded` run the UI offers **Record pack
-as exercised**, which shows what will be written, and on click sets
-`exercise_status: exercised by ` in `pack.yaml` (`rtut run finalize ... --outcome
-succeeded --apply` does the same). The change is an ordinary edit in the owning repository, to
-be reviewed and committed there.
+Finalizing never edits the pack. A clean `succeeded` run becomes evidence for the pack's **maturity** at the
+revision it ran (`docs/maturity-and-projects.md`); this replaces the earlier `exercise_status` field and the
+"Record pack as exercised" step, both retired. Old packs that still carry `exercise_status` load unchanged; the
+field is ignored.
## 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
default), the run stops, or with `on_timeout: push-through` the item is marked done with
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.
- **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
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.
- **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).
@@ -110,16 +109,25 @@ Rules the engine enforces:
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
- accepts is `exercise_recorded`, written by the legacy "Record pack as exercised" step. This holds for runs closed before sealing existed too.
+- **Locked.** A closed record cannot be saved with changes: the engine refuses, naming what would have changed. This holds for runs closed
+ 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
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.
-- **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).
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///record.json`; project runs: `/project-run//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 ` 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
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..ecd6347
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,27 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "runbook-tutorials"
+version = "0.1.0"
+description = "Runbook-tutorial engine and local UI: guided, parameterized operator runbooks; packs live in the repos that own them."
+readme = "README.md"
+requires-python = ">=3.12"
+license = { file = "LICENSE" }
+dependencies = ["PyYAML>=6", "jsonschema>=4"]
+
+[project.optional-dependencies]
+test = ["pytest>=7"]
+
+[project.scripts]
+rtut = "rtut.__main__:cli"
+
+[tool.setuptools]
+packages = ["rtut"]
+
+[tool.setuptools.package-data]
+rtut = ["schema/*.json"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/rtut/__main__.py b/rtut/__main__.py
index cdb59f7..8b41774 100644
--- a/rtut/__main__.py
+++ b/rtut/__main__.py
@@ -5,6 +5,7 @@ import argparse
import sys
from pathlib import Path
+from . import maturity as M
from . import pack as P
@@ -47,24 +48,56 @@ def _run(pk, a) -> int:
R.skip(r, a.step, a.note); return 0
if a.action == "finalize":
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
if a.action == "capture":
R.add_capture(r, a.step, sys.stdin.read(), a.command); print("captured"); return 0
if a.action == "feedback":
R.add_feedback(r, a.rating or "", a.note); print("recorded"); return 0
- old, new = R.exercise_update(r)
- 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
+ raise ValueError(f"unknown action {a.action}")
except (R.RunError, KeyError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
+def _check(root: str) -> int:
+ """One command for CI and for humans: everything declarative in a repository must validate."""
+ from . import project as J
+ from .attrs import Registry
+ reg = Registry([Path(root)])
+ bad = 0
+ print("runbooks")
+ for p in reg.packs.values():
+ print(f" {'ok ' if p.ok else 'FAIL'} {p.id}")
+ for err in p.errors:
+ print(f" - {err}")
+ bad += not p.ok
+ print("attribute sets")
+ for sid, d in reg.sets.items():
+ print(f" {'ok ' if d.ok else 'FAIL'} {sid}")
+ for err in d.errors:
+ print(f" - {err}")
+ bad += not d.ok
+ print("projects")
+ for pid, d in reg.projects.items():
+ errs = J.validate(reg, d)
+ print(f" {'ok ' if not errs else 'FAIL'} {pid}")
+ for err in errs:
+ print(f" - {err}")
+ bad += bool(errs)
+ print("templates")
+ for tid, d in reg.templates.items():
+ print(f" {'ok ' if d.ok else 'FAIL'} {tid}")
+ for err in d.errors:
+ print(f" - {err}")
+ bad += not d.ok
+ total = len(reg.packs) + len(reg.sets) + len(reg.projects) + len(reg.templates)
+ if not total:
+ print("nothing to check", file=sys.stderr)
+ return 1
+ print(f"{total - bad} of {total} ok")
+ return 1 if bad else 0
+
+
def _project(a) -> int:
from . import project as J
from .attrs import Registry
@@ -125,7 +158,7 @@ def main(argv: list[str] | None = None) -> int:
sp = sub.add_parser(name)
sp.add_argument("root", nargs="?", default=".")
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("run_id", nargs="?")
sp.add_argument("step", nargs="?")
@@ -138,10 +171,13 @@ 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("--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("--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.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")
sp.add_argument("manifest")
sp = sub.add_parser("review", help="record that you reviewed the pack at its current revision")
@@ -179,10 +215,20 @@ 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":
return _project(a)
if a.cmd in ("maturity", "review"):
- from . import maturity as M
pk = P.load(Path(a.manifest))
if not pk.ok:
print("\n".join(pk.errors), file=sys.stderr); return 1
@@ -202,7 +248,7 @@ def main(argv: list[str] | None = None) -> int:
for p in _packs(a.root):
if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1":
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
if a.cmd in ("list", "validate"):
packs = _packs(a.root)
@@ -213,7 +259,8 @@ def main(argv: list[str] | None = None) -> int:
for p in packs:
d = p.data
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:
print(f"{'ok ' if p.ok else 'FAIL'} {p.id}")
for e in p.errors:
@@ -246,7 +293,7 @@ def main(argv: list[str] | None = None) -> int:
return 0
if a.cmd == "show":
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):
print(f"{i}. [{s['owner']}] {s['title']} ({s.get('risk', 'read-only')})")
print(f" done when: {s['verify']['done_when']}")
@@ -264,5 +311,10 @@ def main(argv: list[str] | None = None) -> int:
return 0
+def cli() -> None:
+ """Console-script entry point (`rtut`)."""
+ sys.exit(main())
+
+
if __name__ == "__main__":
sys.exit(main())
diff --git a/rtut/attrs.py b/rtut/attrs.py
index ef6cf99..2a14122 100644
--- a/rtut/attrs.py
+++ b/rtut/attrs.py
@@ -16,7 +16,7 @@ from jsonschema import Draft202012Validator
from . import pack as P
from .pack import SECRET_RE
-SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
+SCHEMA_DIR = Path(__file__).resolve().parent / "schema"
def _schema_errors(data: dict, schema_file: str) -> list[str]:
diff --git a/rtut/pack.py b/rtut/pack.py
index 19bc899..61b327f 100644
--- a/rtut/pack.py
+++ b/rtut/pack.py
@@ -9,7 +9,7 @@ from pathlib import Path
import yaml
from jsonschema import Draft202012Validator
-SCHEMA_PATH = Path(__file__).resolve().parent.parent / "schema" / "runbook-pack.schema.json"
+SCHEMA_PATH = Path(__file__).resolve().parent / "schema" / "runbook-pack.schema.json"
PLACEHOLDER = re.compile(r"\{\{\s*([a-z][a-z0-9_]*)\s*\}\}")
SECRET_RE = re.compile(
r"(hvs\.[A-Za-z0-9]{8,}|s\.[A-Za-z0-9]{24}|-----BEGIN [A-Z ]*PRIVATE KEY|otpauth://)")
@@ -143,7 +143,7 @@ REVIEW_FILE = "REVIEWS.yaml"
def digest(pack: "Pack") -> str:
"""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
h = hashlib.sha256()
diff --git a/rtut/projectrun.py b/rtut/projectrun.py
index bb042c7..918ab68 100644
--- a/rtut/projectrun.py
+++ b/rtut/projectrun.py
@@ -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:
diff --git a/rtut/run.py b/rtut/run.py
index 7f20534..79e21df 100644
--- a/rtut/run.py
+++ b/rtut/run.py
@@ -44,19 +44,19 @@ class Run:
def path(self) -> Path:
return runs_dir(self.pack) / f"{self.data['run_id']}.json"
- def save(self, allow: tuple = ()) -> None:
- """Write the record. A closed run is locked: nothing may change except the keys named in `allow`."""
+ def save(self) -> None:
+ """Write the record. A closed run is locked: nothing may change."""
disk = _read_json(self.path)
if disk and disk.get("outcome"):
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:
raise RunError(f"this run is closed and locked ({disk['outcome']}); it cannot be changed "
f"({', '.join(sorted(changed))})")
self.data["updated"] = _now()
self.path.parent.mkdir(parents=True, exist_ok=True)
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")
if self.data.get("outcome"):
self.path.chmod(0o444)
@@ -270,25 +270,6 @@ def skip(run: Run, step_id: str, reason: str) -> None:
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": ":("}
@@ -379,7 +360,8 @@ STALE_DAYS = 7
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
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()
@@ -392,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 `)."""
+ 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:
@@ -503,16 +490,3 @@ def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_D
card["fastest_seconds"] = durations[0] if durations else None
card["timed_runs"] = len(durations)
return card
-
-
-def apply_exercise(run: Run, today: date | None = None) -> str:
- """Write 'exercised by ' 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
diff --git a/schema/attribute-set.schema.json b/rtut/schema/attribute-set.schema.json
similarity index 100%
rename from schema/attribute-set.schema.json
rename to rtut/schema/attribute-set.schema.json
diff --git a/schema/project.schema.json b/rtut/schema/project.schema.json
similarity index 100%
rename from schema/project.schema.json
rename to rtut/schema/project.schema.json
diff --git a/schema/runbook-pack-v0.2.schema.json b/rtut/schema/runbook-pack-v0.2.schema.json
similarity index 98%
rename from schema/runbook-pack-v0.2.schema.json
rename to rtut/schema/runbook-pack-v0.2.schema.json
index 61f1b6b..c7bf5f4 100644
--- a/schema/runbook-pack-v0.2.schema.json
+++ b/rtut/schema/runbook-pack-v0.2.schema.json
@@ -8,7 +8,6 @@
"id",
"title",
"owner",
- "exercise_status",
"engine"
],
"additionalProperties": false,
@@ -33,7 +32,8 @@
},
"exercise_status": {
"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": [
diff --git a/rtut/schema/runbook-pack.schema.json b/rtut/schema/runbook-pack.schema.json
new file mode 100644
index 0000000..e551de6
--- /dev/null
+++ b/rtut/schema/runbook-pack.schema.json
@@ -0,0 +1,294 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://runbook-tutorials.invalid/schema/runbook-pack/v0.1",
+ "title": "Runbook pack manifest",
+ "type": "object",
+ "required": [
+ "spec",
+ "id",
+ "title",
+ "owner",
+ "engine"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "spec": {
+ "const": "runbook-pack/v0.1"
+ },
+ "id": {
+ "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": {
+ "type": "string",
+ "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"
+ ]
+ },
+ "legacy": {
+ "type": "object",
+ "required": [
+ "command"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "command": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "cwd": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "note": {
+ "type": "string"
+ }
+ }
+ },
+ "parameters": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/parameter"
+ }
+ },
+ "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": [
+ {
+ "if": {
+ "properties": {
+ "engine": {
+ "const": "native"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "steps",
+ "threat_checks"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "engine": {
+ "const": "legacy-command"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "legacy"
+ ]
+ }
+ }
+ ],
+ "$defs": {
+ "parameter": {
+ "type": "object",
+ "required": [
+ "id",
+ "label",
+ "type"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]*$"
+ },
+ "label": {
+ "type": "string"
+ },
+ "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": {
+ "type": "object",
+ "required": [
+ "text",
+ "owner"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "owner": {
+ "type": "string"
+ }
+ }
+ },
+ "check": {
+ "type": "object",
+ "required": [
+ "done_when"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "done_when": {
+ "type": "string",
+ "minLength": 3
+ },
+ "command": {
+ "type": "string"
+ },
+ "expect": {
+ "enum": [
+ "manual",
+ "exit-0",
+ "output-contains"
+ ]
+ },
+ "contains": {
+ "type": "string"
+ }
+ }
+ },
+ "step": {
+ "type": "object",
+ "required": [
+ "id",
+ "title",
+ "owner",
+ "verify"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]*$"
+ },
+ "title": {
+ "type": "string"
+ },
+ "owner": {
+ "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": {
+ "type": "object",
+ "required": [
+ "concern",
+ "owner"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "concern": {
+ "type": "string"
+ },
+ "owner": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
diff --git a/rtut/ui.py b/rtut/ui.py
index f604487..f8699dc 100644
--- a/rtut/ui.py
+++ b/rtut/ui.py
@@ -260,7 +260,7 @@ class App:
if x.get("values") else f"")
+ f" {e(x.get('help', ''))} Never enter secrets.
"
for x in d.get("parameters", []))
- return page(d["title"], f"
{e(d['title'])}
{e(d['exercise_status'])}
"
+ return page(d["title"], f"
{e(d['title'])}
{UP.maturity_pill(p)}
"
f"
{e(d.get('outcome', ''))}
{e(score_line(R.scorecard(p)))}
"
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}
"
states = [v["state"] for v in r.data["steps"].values()]
btns = []
@@ -383,7 +374,7 @@ class App:
where = (f" · project {e(ctx['project'])}"
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
f"{' · part of project run ' + e(ctx['project_run']) + '' if ctx.get('project_run') else ''}")
- out = [f"
{e(d['title'])}
{e(d['exercise_status'])}
"
+ out = [f"
{e(d['title'])}
{UP.maturity_pill(p)}
"
f"
run {e(rid)} · operator {e(r.data['operator'])}{where}
"
f"
{UI2.times_line(r)}
"]
step_secs = R.run_times(r)["step_seconds"]
@@ -491,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 `."""
+ 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"))
@@ -517,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"))
@@ -607,15 +617,10 @@ def make_handler(app: App, port_holder: list):
i, rid = int(parts[1]), parts[2]
R.add_feedback(R.load(app.packs[i], rid), form.get("rating", ""), form.get("comment", ""))
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]
- r = R.load(app.packs[i], rid)
- if parts[3] == "finalize":
- 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
+ R.finalize(R.load(app.packs[i], rid), form.get("outcome", ""), form.get("note", ""),
+ rating=form.get("rating") or None, comment=form.get("fb_comment", ""))
return self._redirect(f"/run/{i}/{rid}#close")
i, rid, sid, action = int(parts[1]), parts[2], parts[3], parts[4]
p = app.packs[i]; r = R.load(p, rid)
diff --git a/rtut/ui_projectrun.py b/rtut/ui_projectrun.py
index 1d89f82..9d7d684 100644
--- a/rtut/ui_projectrun.py
+++ b/rtut/ui_projectrun.py
@@ -162,7 +162,9 @@ def _close(app, reg, pr, v: dict) -> str:
note = f"
Comment: {e(d['outcome_note'])}
" if d.get("outcome_note") else ""
broken = ("
⚠ This project run's record was changed outside the app after it was sealed.
" if state == "broken" else
"
This project run is closed and locked: its record is sealed and cannot be changed. Start a new project run to try again.
")
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]
diff --git a/rtut/v2.py b/rtut/v2.py
index 3fc6463..69f8284 100644
--- a/rtut/v2.py
+++ b/rtut/v2.py
@@ -9,7 +9,7 @@ from jsonschema import Draft202012Validator
from .pack import PLACEHOLDER, SECRET_RE
-SCHEMA_PATH = Path(__file__).resolve().parent.parent / "schema" / "runbook-pack-v0.2.schema.json"
+SCHEMA_PATH = Path(__file__).resolve().parent / "schema" / "runbook-pack-v0.2.schema.json"
MAX_DEPTH = 3
diff --git a/runbooks/example-hello/pack.yaml b/runbooks/example-hello/pack.yaml
index 7a00553..7b277b4 100644
--- a/runbooks/example-hello/pack.yaml
+++ b/runbooks/example-hello/pack.yaml
@@ -3,7 +3,6 @@ id: rtut.example-hello
title: Example - check a host answers
owner: runbook-tutorials
outcome: You have proven a host answers on the network.
-exercise_status: exercised 2026-09-29 by Bernd
engine: native
parameters:
- {id: host, label: Host name, type: string, default: localhost, pattern: "[a-zA-Z0-9.-]+"}
diff --git a/runbooks/showcase-lighthouse-v2/pack.yaml b/runbooks/showcase-lighthouse-v2/pack.yaml
index f446b35..63dadc6 100644
--- a/runbooks/showcase-lighthouse-v2/pack.yaml
+++ b/runbooks/showcase-lighthouse-v2/pack.yaml
@@ -5,7 +5,6 @@ id: rtut.showcase-lighthouse-v2
title: "Showcase: a night at the lighthouse (v0.2)"
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-.
-exercise_status: unexercised
engine: native
confirm_phrase: I understand this cannot be undone
diff --git a/runbooks/showcase-lighthouse/pack.yaml b/runbooks/showcase-lighthouse/pack.yaml
index cc9520b..1ea7e08 100644
--- a/runbooks/showcase-lighthouse/pack.yaml
+++ b/runbooks/showcase-lighthouse/pack.yaml
@@ -3,7 +3,6 @@ id: rtut.showcase-lighthouse
title: "Showcase: a night at the lighthouse"
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.
-exercise_status: unexercised
engine: native
parameters:
- {id: keeper, label: Your keeper's name, type: string, default: Ada, pattern: "[A-Za-z]+", help: One word. Used in the story.}
diff --git a/schema/runbook-pack.schema.json b/schema/runbook-pack.schema.json
deleted file mode 100644
index cada4c4..0000000
--- a/schema/runbook-pack.schema.json
+++ /dev/null
@@ -1,98 +0,0 @@
-{
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "https://runbook-tutorials.invalid/schema/runbook-pack/v0.1",
- "title": "Runbook pack manifest",
- "type": "object",
- "required": ["spec", "id", "title", "owner", "exercise_status", "engine"],
- "additionalProperties": false,
- "properties": {
- "spec": {"const": "runbook-pack/v0.1"},
- "id": {"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": {
- "type": "string",
- "pattern": "^(unexercised|exercised [0-9]{4}-[0-9]{2}-[0-9]{2} by \\S+)$"
- },
- "engine": {"enum": ["native", "legacy-command"]},
- "legacy": {
- "type": "object",
- "required": ["command"],
- "additionalProperties": false,
- "properties": {
- "command": {"type": "array", "items": {"type": "string"}, "minItems": 1},
- "cwd": {"type": "string"},
- "url": {"type": "string"},
- "note": {"type": "string"}
- }
- },
- "parameters": {"type": "array", "items": {"$ref": "#/$defs/parameter"}},
- "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": [
- {"if": {"properties": {"engine": {"const": "native"}}},
- "then": {"required": ["steps", "threat_checks"]}},
- {"if": {"properties": {"engine": {"const": "legacy-command"}}},
- "then": {"required": ["legacy"]}}
- ],
- "$defs": {
- "parameter": {
- "type": "object",
- "required": ["id", "label", "type"],
- "additionalProperties": false,
- "properties": {
- "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
- "label": {"type": "string"},
- "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": {
- "type": "object",
- "required": ["text", "owner"],
- "additionalProperties": false,
- "properties": {"text": {"type": "string"}, "owner": {"type": "string"}}
- },
- "check": {
- "type": "object",
- "required": ["done_when"],
- "additionalProperties": false,
- "properties": {
- "done_when": {"type": "string", "minLength": 3},
- "command": {"type": "string"},
- "expect": {"enum": ["manual", "exit-0", "output-contains"]},
- "contains": {"type": "string"}
- }
- },
- "step": {
- "type": "object",
- "required": ["id", "title", "owner", "verify"],
- "additionalProperties": false,
- "properties": {
- "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"},
- "title": {"type": "string"},
- "owner": {"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": {
- "type": "object",
- "required": ["concern", "owner"],
- "additionalProperties": false,
- "properties": {"concern": {"type": "string"}, "owner": {"type": "string"}}
- }
- }
-}
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..26f04b3
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,141 @@
+"""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"])
+
+
+def test_check_validates_everything_declarative_and_reports_problems(repo, capsys):
+ (repo / "runbooks/v1/pack.yaml").write_text((repo / "runbooks/v1/pack.yaml").read_text().replace("engine: native", "engine: nonsense"))
+ code, out, _ = run(capsys, "check", repo)
+ assert code == 1 and "FAIL rtut.showcase-lighthouse" in out and "ok rtut.showcase-lighthouse-v2" in out
+ assert "attribute sets" in out and "ok harbour-defaults" in out and "templates" in out and "of" in out.splitlines()[-1]
+
+
+def test_check_passes_on_a_clean_repository_and_fails_on_an_empty_one(repo, capsys, tmp_path):
+ code, out, _ = run(capsys, "check", repo)
+ assert code == 0 and out.rstrip().endswith("4 of 4 ok") # two packs, one attribute set, one template
+ empty = tmp_path / "empty"; empty.mkdir()
+ code, _, err = run(capsys, "check", empty)
+ assert code == 1 and "nothing to check" in err
+
+
+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
diff --git a/tests/test_legacy.py b/tests/test_legacy.py
index b067a46..8fdc417 100644
--- a/tests/test_legacy.py
+++ b/tests/test_legacy.py
@@ -17,7 +17,7 @@ def make_repo(tmp_path, command, url, cwd="."):
d.mkdir(parents=True)
(d / "pack.yaml").write_text(yaml.safe_dump({
"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}}))
return P.load(d / "pack.yaml")
diff --git a/tests/test_maturity.py b/tests/test_maturity.py
index 8d74665..e223daf 100644
--- a/tests/test_maturity.py
+++ b/tests/test_maturity.py
@@ -33,8 +33,10 @@ def test_digest_is_stable_ignores_exercise_status_and_reviews_but_changes_with_c
d1 = P.digest(pk)
assert d1 == P.digest(pk)
text = pk.path.read_text()
- pk.path.write_text(text.replace("exercise_status: unexercised", "exercise_status: exercised 2026-09-29 by x"))
- assert P.digest(pk) == d1 # recording an exercise is not a new revision
+ assert "engine: native\n" in text
+ 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")
assert P.digest(pk) == d1 # neither is a review
pk.path.write_text(text + "\n# changed\n")
diff --git a/tests/test_pack.py b/tests/test_pack.py
index 3c4e86b..52c2983 100644
--- a/tests/test_pack.py
+++ b/tests/test_pack.py
@@ -29,8 +29,6 @@ def mut(fn):
@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["steps"][0].pop("verify"), "verify"),
(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):
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
d["legacy"] = {"command": ["true"]}
assert check(tmp_path, d).ok
@@ -74,3 +72,13 @@ def test_text_parameter_allows_spaces_but_not_metacharacters():
with pytest.raises(ValueError):
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
diff --git a/tests/test_projectrun.py b/tests/test_projectrun.py
index 55cb348..db4430b 100644
--- a/tests/test_projectrun.py
+++ b/tests/test_projectrun.py
@@ -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"}}]
if 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"}],
"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}]}
diff --git a/tests/test_run.py b/tests/test_run.py
index c6bb628..f3bc62a 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -51,9 +51,7 @@ def test_full_run_and_exercise(pk):
pytest.skip("ping unavailable in this environment")
R.complete(r, "ping")
assert r.complete
- old, new = R.exercise_update(r, date(2026, 9, 29))
- 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
+ assert pk.path.read_text() == P.load(pk.path).path.read_text() # the engine never writes a pack
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-----")
r2 = R.create(pk, "bernd")
R.skip(r2, "ping", "not needed")
- with pytest.raises(R.RunError):
- R.exercise_update(r2) # skips never exercise a pack
+ with pytest.raises(R.RunError, match="succeeds only when every step is done"):
+ R.finalize(r2, "succeeded") # a skipped step never counts as done
def test_manual_verification_needs_answer(pk):
diff --git a/tests/test_run2.py b/tests/test_run2.py
index e8f248e..4bb2ef3 100644
--- a/tests/test_run2.py
+++ b/tests/test_run2.py
@@ -60,6 +60,14 @@ def pass_gate(r, gid):
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 -----------------------------------------------------------------------------------
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")
-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.start(r, "warn", alt="sound-horn", override_reason="probe", acknowledge_risk=True)
- r.data["steps"]["warn"]["state"] = "failed"
- assert r.data["deviations"]
- with pytest.raises(R1.RunError, match="deviations"):
- for k in r.data["steps"]:
- r.data["steps"][k]["state"] = "done"
- R1.exercise_update(r)
+ assert r.data["deviations"] and not counts_towards_maturity(r)
+ assert counts_towards_maturity(new(tmp_path)) # the same without a deviation would count
# ---- 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
nxt = R.auto_confirm_tick(r, late) # continues to the manual-only step
assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator"
- with pytest.raises(R1.RunError, match="deviations"):
- for k in r.data["steps"]:
- r.data["steps"][k]["state"] = "done"
- R1.exercise_update(r)
+ assert not counts_towards_maturity(r) # a pushed-through run is not evidence
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)
do_step(r, "read-the-sky")
R.set_datapoint(r, "weather", "clear", override_reason="changed my mind", acknowledge_risk=True)
- assert r.data["deviations"]
- with pytest.raises(R1.RunError, match="deviations"):
- for k in r.data["steps"]:
- r.data["steps"][k]["state"] = "done"
- R1.exercise_update(r)
+ assert r.data["deviations"] and not counts_towards_maturity(r)
diff --git a/tests/test_score.py b/tests/test_score.py
index 548974c..b2df03f 100644
--- a/tests/test_score.py
+++ b/tests/test_score.py
@@ -64,17 +64,6 @@ def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
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):
r = R.create(pk, "bernd")
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"
-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")
r2 = R.load(pk, r.data["run_id"])
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"):
r3.save()
assert R.load(pk, r.data["run_id"]).data["operator"] == "bernd" # nothing reached the disk
- r4 = R.load(pk, r.data["run_id"])
- 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"
+ assert R.seal_state(R.load(pk, r.data["run_id"])) == "sealed" # and the seal still holds
def test_closing_seals_the_file_read_only_and_tampering_is_detected(pk):
diff --git a/tests/test_showcase.py b/tests/test_showcase.py
index 68cb533..c26080e 100644
--- a/tests/test_showcase.py
+++ b/tests/test_showcase.py
@@ -35,8 +35,8 @@ def test_scripted_playthrough(pk):
R.complete(r, sid, {n: "1" for n in s.get("evidence", [])})
assert r.complete
assert not Path(r.data["params"]["logdir"]).exists()
- old, new = R.exercise_update(r)
- assert "exercised" in new
+ R.finalize(r, "succeeded") # every step done: the run may succeed
+ assert r.data["outcome"] == "succeeded"
def test_bad_logdir_rejected(pk):
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 88bd957..200d48a 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -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
_, _, body = req(srv, "GET", base)
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
_, _, body = req(srv, "GET", "/")
assert "succeeded 1" in body
- # exercised status is only written on the explicit click
- 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
+ assert req(srv, "POST", base + "/exercise", {"_t": t})[0] == 400 # the route is gone
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
st, _, body = req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
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):
diff --git a/tests/test_ui_locked.py b/tests/test_ui_locked.py
index e2b7a25..2ff9afa 100644
--- a/tests/test_ui_locked.py
+++ b/tests/test_ui_locked.py
@@ -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"Download the run record (JSON)" 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
diff --git a/tests/test_ui_projectrun.py b/tests/test_ui_projectrun.py
index c2a949d..e15befb 100644
--- a/tests/test_ui_projectrun.py
+++ b/tests/test_ui_projectrun.py
@@ -19,7 +19,7 @@ def v2_pack(pid, manual_only_last=False):
if manual_only_last:
steps.append({"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only",
"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"}],
"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}]}
@@ -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"Download the project run record (JSON)" 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"
diff --git a/tests/test_v2.py b/tests/test_v2.py
index d27836e..72c1b52 100644
--- a/tests/test_v2.py
+++ b/tests/test_v2.py
@@ -106,7 +106,7 @@ def test_three_levels_allowed():
def test_legacy_v02_needs_only_schema():
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) == []
@@ -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 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
+
+
+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) == []
diff --git a/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md b/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md
index 6d3791a..c35003e 100644
--- a/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md
+++ b/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md
@@ -84,19 +84,21 @@ Done 2026-09-29: `schema/runbook-pack-v0.2.schema.json` and `rtut/v2.py` (acycli
```task
id: RBT-WP-0002-T06
-status: wait
+status: done
priority: medium
state_hub_task_id: "dae5a6fe-9f1f-553a-8b73-f47ab6d91b59"
```
+2026-09-29 (CUST-WP-0074-T05 backfill): this task was `wait` with no external dependency or human gate stated, which Task Wait Qualifiers v0.1 treats as unqualified; set to `todo`. Re-add `wait` with a qualifier if a real gate exists.
-Port the four validating packs to v0.2 where it helps, and add v0.2 test packs with
+
+Done 2026-09-29 (RBT-WP-0009-T06): the three NetKingdom packs are v0.2 (the console stays a legacy pack); the lighthouse v0.2 showcase and the test packs cover
alternatives, gates and actors.
## Showcase pack as acceptance test
```task
id: RBT-WP-0002-T07
-status: progress
+status: done
priority: medium
state_hub_task_id: "6740457f-81a7-5761-a768-b9b0faee7552"
```
diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md
index baa1c98..5b17cff 100644
--- a/workplans/RBT-WP-0004-local-runbook-ui.md
+++ b/workplans/RBT-WP-0004-local-runbook-ui.md
@@ -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
diff --git a/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md b/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md
index d6673b4..82411be 100644
--- a/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md
+++ b/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md
@@ -50,12 +50,12 @@ with a stub server.
```task
id: RBT-WP-0005-T03
-status: todo
+status: done
priority: high
state_hub_task_id: "dbb5553d-fb0c-5298-85c8-cebc9dd63312"
```
-Classify the 5,300-line `security_bootstrap_console.py` into generic engine
+Done 2026-09-29 (RBT-WP-0009-T05): `docs/console-mapping.md`. Classify the 5,300-line `security_bootstrap_console.py` into generic engine
concerns (record and state model, gates, stage derivation, copyable blocks,
approval phrase, localhost server) and NetKingdom-specific content (custody,
OpenBao ceremony, KeyCape, privacyIDEA checks). Output: a migration map with
@@ -65,12 +65,13 @@ the tests that pin current behaviour.
```task
id: RBT-WP-0005-T04
-status: todo
+status: cancel
priority: medium
state_hub_task_id: "14261c00-65d0-55be-8ab2-bd81e579e20d"
```
-Move the generic parts into `rtut` behind the run record. The console in
+Cancelled 2026-09-29: extracting the console's engine now would freeze its workarounds into the engine, because v0.2 lacks conditional applicability, gate
+recheck and outputs that feed later members (`docs/console-mapping.md` G1, G3, G7). It may return as part of a v0.3 design. Original scope: move the generic parts into `rtut` behind the run record. The console in
`net-kingdom` then imports them. Gate: the existing
`tools/security-bootstrap-console/tests` pass unchanged before and after, and
`make security-bootstrap-ui` behaves the same.
@@ -79,13 +80,14 @@ Move the generic parts into `rtut` behind the run record. The console in
```task
id: RBT-WP-0005-T05
-status: todo
+status: done
priority: low
state_hub_task_id: "c54cfa35-1a34-5d5a-b85b-86ae8771dcfb"
```
-With Bernd: keep the console as a permanent legacy pack, migrate its runbooks
-to native packs, or a mix. Record as a decision.
+Decided 2026-09-29 (Bernd, with RBT-WP-0009): the console stays a permanent legacy pack, launched unchanged. Its seven runbooks can become native v0.2
+packs as separate content work (they need none of G1 to G7). Original question: keep the console as a permanent legacy pack, migrate its runbooks
+to native packs, or a mix.
## Acceptance Criteria
diff --git a/workplans/RBT-WP-0006-execution-mode-and-pack-index.md b/workplans/RBT-WP-0006-execution-mode-and-pack-index.md
index 6ebdd95..5851308 100644
--- a/workplans/RBT-WP-0006-execution-mode-and-pack-index.md
+++ b/workplans/RBT-WP-0006-execution-mode-and-pack-index.md
@@ -24,6 +24,8 @@ discovery of packs across the estate.
```task
id: RBT-WP-0006-T01
status: wait
+needs_human: true
+blocking_reason: "Needs a decision on which identity runs the command."
priority: low
state_hub_task_id: "0424deab-3b51-5222-80b1-54c0ea26928d"
```
@@ -37,6 +39,8 @@ decision on which identity runs the command.
```task
id: RBT-WP-0006-T02
status: wait
+needs_human: true
+blocking_reason: "Needs a decision on scan scope."
priority: low
state_hub_task_id: "0cfb69bc-dc7e-5960-8359-64c49a742a6e"
```
@@ -49,6 +53,8 @@ a capability entry in the registry. Needs a decision on scan scope.
```task
id: RBT-WP-0006-T03
status: wait
+needs_human: true
+blocking_reason: "Needs decisions on access, identity and what is sent where."
priority: low
state_hub_task_id: "ce82d1ae-76a3-59c8-9d26-4813e8dc9745"
```
@@ -62,6 +68,8 @@ feedback, confirm loop. Needs decisions on access, identity and what is sent whe
```task
id: RBT-WP-0006-T04
status: wait
+needs_human: true
+blocking_reason: "Needs a decision on how a pack in another repo is identified and trusted."
priority: low
state_hub_task_id: "acd969c3-3bd4-5953-9f9a-8ad8c5d8e380"
```
diff --git a/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md b/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md
new file mode 100644
index 0000000..e92eff5
--- /dev/null
+++ b/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md
@@ -0,0 +1,121 @@
+---
+id: RBT-WP-0009
+type: workplan
+title: "Finalize the runbook-tutorials workplans"
+domain: infotech
+repo: runbook-tutorials
+status: active
+flavor: implementation
+owner: claude
+topic_slug: stack
+created: "2026-09-29"
+updated: "2026-09-29"
+depends_on: [RBT-WP-0002, RBT-WP-0004, RBT-WP-0005, RBT-WP-0006, RBT-WP-0007, RBT-WP-0008]
+---
+
+## Goal
+
+Close every runbook-tutorials workplan before new functionality is introduced. "Closed" means finished, or moved to
+`backlog` with the open decisions written down. Agreed with Bernd on 2026-09-29: the definition of done is *all workplans closed*
+(no formal freeze ceremony; a security and code review remains an optional follow-up).
+
+Decisions taken with the plan: keep old packs that still carry `exercise_status` loadable (ignored, deprecated); keep the
+NetKingdom security bootstrap console as a permanent legacy pack; port the three NetKingdom packs to v0.2; move terminal mode to the
+backlog and do only a small JSON export.
+
+## Retire exercise_status
+
+```task
+id: RBT-WP-0009-T01
+status: done
+priority: high
+```
+
+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
+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
+
+```task
+id: RBT-WP-0009-T02
+status: done
+priority: high
+```
+
+Done 2026-09-29. Declare the dependencies (`pyproject.toml`: PyYAML, jsonschema, pytest) so a fresh checkout can run; add `rtut check` (packs, attribute
+sets, projects and templates in one go) and `make check`; replace the copied smoke-only CI with a job that runs the tests and the checks.
+Also: the JSON schemas moved into the package (`rtut/schema/`) so a plain `pip install .` works (verified); `rtut check` validates packs, attribute
+sets, projects and templates; `.forgejo/workflows/tests.yaml` runs tests and checks in a python:3.12 container. **The CI job has not run on a real
+runner** (no way to trigger one from here); check its first execution.
+
+## Documents and bookkeeping
+
+```task
+id: RBT-WP-0009-T03
+status: todo
+priority: medium
+```
+
+Refresh `SCOPE.md` (it still says "bootstrap"), add a `docs/README.md` index, note in the specs that v0.1 and v0.2 changes from here on
+become a new version, update the capability entry, and make the workplan statuses true (RBT-WP-0007 finished, RBT-WP-0002-T07 done,
+RBT-WP-0006 and NK-WP-0011 to backlog).
+
+## Evidence export
+
+```task
+id: RBT-WP-0009-T04
+status: done
+priority: medium
+```
+
+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 ` so an exported record can be verified anywhere.
+
+## Console mapping: is v0.2 expressive enough
+
+```task
+id: RBT-WP-0009-T05
+status: done
+priority: high
+```
+
+Done 2026-09-29. Verdict: the structure maps, the behaviour does not fully; seven gaps (G1 to G7) are the v0.3 candidates. `docs/console-mapping.md`: map the security bootstrap console's 6 stages, 14 gates, roles, subsystems, integrations, 12 artefacts
+and 7 runbooks onto v0.2 and say honestly where a special case is needed. This is the acceptance evidence for the model. It closes
+RBT-WP-0005-T03, records the decision to keep the console as a permanent legacy pack (T05), and moves the extraction (T04) to the backlog.
+
+## Port the NetKingdom packs to v0.2
+
+```task
+id: RBT-WP-0009-T06
+status: done
+priority: high
+```
+
+Done 2026-09-29 (net-kingdom commit 90112f3). `nk.ssh-certificates`, `nk.openbao-operating-path` and `nk.protected-system-flex-auth` become v0.2 packs with actors, datapoints,
+stages, gates, idempotency and framing, so the real content uses the model and exercise evidence attaches to the format that stays.
+Closes RBT-WP-0002-T06 and updates NK-WP-0044.
+All three validate as v0.2; the 13 checks that need no cluster were re-run through the engine and pass; the OpenBao pack's optional record step is an
+alternatives choice recommended by a datapoint; live flex-auth steps are manual-only for auto-confirm. The attended run against the cluster is
+Bernd's (NK-WP-0044-T05).
+
+## Close the workplans
+
+```task
+id: RBT-WP-0009-T07
+status: todo
+priority: high
+```
+
+Set every status to what is true, move terminal mode and the evidence work that is not done to the backlog with the decisions written
+down, archive the finished workplans, sync State Hub, and record a progress event. Work that needs Bernd's own environment
+(NK-WP-0044-T05 attended runs, NK-WP-0009 T03-T05, the external STS issuer for T02) stays in net-kingdom and is named there.
+
+## Acceptance Criteria
+
+- No runbook-tutorials workplan is `active` with open `todo` work: each is `finished` or `backlog` with its decisions recorded.
+- `exercise_status` is gone from the UI, the engine and the shipped packs; old packs still load.
+- A fresh checkout can install and run the tests; CI runs them.
+- The console mapping states plainly what v0.2 cannot express yet.
+- The three NetKingdom packs validate as v0.2.