Compare commits

...

6 commits

Author SHA1 Message Date
f3785dd2dc Record the NetKingdom port (RBT-WP-0009-T06)
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Tests / tests (push) Waiting to run
Work Records / validate (push) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
2026-09-29 21:40:57 +02:00
f52a3e14f4 Console mapping: is v0.2 expressive enough (RBT-WP-0009-T05); RBT-WP-0005 decisions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
2026-09-29 21:37:21 +02:00
6d70f4d97d Evidence export of closed runs and rtut seal (RBT-WP-0009-T04)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
2026-09-29 21:35:03 +02:00
e021fd979f Packaging, rtut check, tests CI job; schemas move into the package (RBT-WP-0009-T02)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
2026-09-29 21:32:19 +02:00
6d3414d8b7 Retire exercise_status: maturity is the only notion; add CLI tests (RBT-WP-0009-T01)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
2026-09-29 21:28:56 +02:00
b89e8eaa74 Qualify wait tasks per Task Wait Qualifiers v0.1 (CUST-WP-0074-T05, long tail)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
2026-09-29 21:25:00 +02:00
46 changed files with 1045 additions and 298 deletions

View file

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

View file

@ -23,8 +23,8 @@ verification and rollback, and non-secret evidence of what actually happened.
- **Parameters, not copies.** A runbook declares its parameters; the engine
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

View file

@ -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)

View file

@ -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>/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 <repo-root> # validate every pack
python3 -m rtut list <repo-root> # id, engine, exercise status, title
python3 -m rtut list <repo-root> # id, engine, maturity, title
python3 -m rtut show <pack.yaml> # steps with owners and done-when
python3 -m rtut render <pack.yaml> <step> -p name=value
python3 -m rtut launch <pack.yaml> # legacy-command packs: start the existing console
@ -19,10 +31,8 @@ python3 -m rtut maturity <pack.yaml> # draft | reviewed | rehearsed | prove
python3 -m rtut review <pack.yaml> --by you # record a review of the current revision
python3 -m rtut project list|validate|status|attrs|new|run <repo-root> ... # projects, attribute sets, templates
python3 -m rtut ui <repo-root> [<repo-root> ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks; v0.1 and v0.2 runs (guided flow, data view, overview)
python3 -m rtut run new|start|verify|done|finish <pack.yaml> ... # the same run record from the terminal
python3 -m rtut run new|start|verify|done|finalize <pack.yaml> ... # the same run record from the terminal
make test
```
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/`.

View file

@ -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

109
docs/console-mapping.md Normal file
View file

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

View file

@ -91,7 +91,7 @@ A **rehearsal** project is where maturity evidence comes from. "Exercised" then
- Runs record `project`, the environment class and the pack digest. Scores become per project as well as per
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/<name>/project.yaml`), next to `runbooks/`.

View file

@ -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)

View file

@ -2,7 +2,7 @@
A **runbook pack** is a directory `runbooks/<pack-dir>/` 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 <operator>` |
| `exercise_status` | no | **Deprecated and ignored.** Maturity is derived from run history; old packs that carry it still load |
| `engine` | yes | `native` or `legacy-command` |
| `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

View file

@ -18,14 +18,14 @@ One run of one native pack, stored as JSON in
- Steps run in order; a step starts only after its predecessors are done or skipped.
- 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 <pack.yaml> ...`
CLI: `rtut run new|list|status|cmd|start|verify|done|fail|retry|skip|feedback|finalize|capture <pack.yaml> ...`
## Outcome, score and exercise status
## Outcome and score
A run is unfinished (**stalled**) until finalized. `rtut run finalize` (or the UI's *Close this run*) records
an `outcome`:
@ -41,11 +41,10 @@ its run records: tried, succeeded, failed, abandoned, stalled (unfinished), plus
(`rtut score <root>`; shown in the UI). Run records live in `.local/` of the machine that ran
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 <date> by <operator>` 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/<i>/<run>/record.json`; project runs: `/project-run/<id>/record.json`),
exactly as stored. An open run has no final record (409). The response carries `X-Rtut-Seal` (`sealed`, `broken` or `unsealed`), so a record that
was changed on disk is still downloadable but flagged. **`rtut seal <file>` checks an exported record anywhere**: it recomputes the SHA-256 over
the record (without `seal` and `updated`) and prints `sealed` (exit 0), `broken` (1), or `unsealed`/`open` (2). The record is non-secret by
construction (secret-looking text is refused everywhere it could be recorded); it does include any output the operator chose to capture, so
read it before sharing.
## Changing a value after steps used it
A v0.1 run fixes its parameters when it starts. In a v0.2 run datapoints can be edited (guided flow questions, data view), but a value

27
pyproject.toml Normal file
View file

@ -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"]

View file

@ -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())

View file

@ -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]:

View file

@ -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()

View file

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

View file

@ -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 <file>`)."""
if not data.get("outcome"):
return "open"
if not data.get("seal"):
return "unsealed"
return "sealed" if data["seal"] == _seal_of(data) else "broken"
def seal_state(run: Run) -> str:
"""'open' | 'sealed' | 'broken' (changed after closing, outside the app) | 'unsealed' (closed before sealing existed).
Judged on what is on disk, not on the in-memory copy."""
disk = _read_json(run.path) or run.data
if not disk.get("outcome"):
return "open"
if not disk.get("seal"):
return "unsealed"
return "sealed" if disk["seal"] == _seal_of(disk) else "broken"
return record_seal_state(_read_json(run.path) or run.data)
def finalize(run: Run, outcome: str, note: str = "", rating: str | None = None, comment: str = "") -> None:
@ -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 <date> by <operator>' into pack.yaml for a succeeded run. Returns the new status."""
if run.data.get("outcome") != "succeeded":
raise RunError("only a run finished as succeeded can exercise the pack")
_, new = exercise_update(run, today)
run.pack.path.write_text(new)
status = STATUS_LINE.search(new).group(0).split(": ", 1)[1]
run.pack.data["exercise_status"] = status
run.data["exercise_recorded"] = status
run.save(allow=("exercise_recorded",)) # the one thing a closed run may still receive
return status

View file

@ -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": [

View file

@ -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"
}
}
}
}
}

View file

@ -260,7 +260,7 @@ class App:
if x.get("values") else f"<input name='p_{x['id']}' value='{e(x.get('default', ''))}'>")
+ f"</label><br><span class=note>{e(x.get('help', ''))} Never enter secrets.</span></p>"
for x in d.get("parameters", []))
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
return page(d["title"], f"<h1>{e(d['title'])}</h1><p>{UP.maturity_pill(p)}</p>"
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}"
@ -301,18 +301,9 @@ class App:
outcome = r.data.get("outcome")
if outcome:
note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else ""
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
if outcome == "succeeded":
if r.data.get("exercise_recorded"):
box += f"<p>Pack recorded as <b>{e(r.data['exercise_recorded'])}</b>. Commit the change in the owning repository.</p>"
else:
old, new = R.exercise_update(r)
line = new.split("exercise_status:", 1)[1].split("\n", 1)[0].strip()
box += (f"<p>Current pack status: <span class=pill>{e(p.data['exercise_status'])}</span>. "
f"Recording this run would set it to <b>{e(line)}</b> in <code>{e(p.path)}</code> "
"(a reviewable edit; commit it in the owning repository).</p>"
f"<form method=post action='/run/{i}/{e(rid)}/exercise'>{self.hidden()}"
"<button>Record pack as exercised</button></form>")
box = (f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
f"<p><a href='/run/{i}/{e(rid)}/record.json'>Download the run record (JSON)</a> "
"<span class=note>Check it anywhere with <code>rtut seal &lt;file&gt;</code>.</span></p>")
return f"<div id=close class=step><h2>Close this run</h2>{box}</div>"
states = [v["state"] for v in r.data["steps"].values()]
btns = []
@ -383,7 +374,7 @@ class App:
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}")
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
out = [f"<h1>{e(d['title'])}</h1><p>{UP.maturity_pill(p)}</p>"
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"
f"<p class=note>{UI2.times_line(r)}</p>"]
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 <file>`."""
import json
data = json.loads(path.read_text())
if not data.get("outcome"):
return self._send_file(409, b"the record can be downloaded once the run is closed", "text/plain; charset=utf-8", None)
state = R.record_seal_state(data)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Rtut-Seal", state)
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
self.send_header("Content-Disposition", f"attachment; filename=\"{filename}\"")
self.end_headers(); self.wfile.write(path.read_bytes())
def _host_ok(self) -> bool:
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost"))
@ -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)

View file

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

View file

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

View file

@ -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

View file

@ -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.-]+"}

View file

@ -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

View file

@ -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.}

View file

@ -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"}}
}
}
}

141
tests/test_cli.py Normal file
View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -19,7 +19,7 @@ def v2_pack(pid, extra_step=None):
{"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}]
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}]}

View file

@ -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):

View file

@ -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)

View file

@ -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):

View file

@ -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):

View file

@ -245,17 +245,11 @@ def test_score_runs_list_and_finalize_flow(srv):
assert req(srv, "POST", base + "/finalize", {"_t": t, "outcome": "succeeded", "note": "smooth"})[0] == 303
_, _, 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):

View file

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

View file

@ -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"<a href='{loc}/record.json'>Download the project run record (JSON)</a>" in req(srv, "GET", loc)[2]
c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1])
c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"})
r = c.getresponse(); body = r.read()
assert r.status == 200 and r.getheader("X-Rtut-Seal") == "sealed" and "attachment" in r.getheader("Content-Disposition")
rec = json.loads(body)
assert rec["project"] == "demo" and rec["outcome"] == "abandoned" and RR.record_seal_state(rec) == "sealed"

View file

@ -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) == []

View file

@ -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"
```

View file

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

View file

@ -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

View file

@ -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"
```

View file

@ -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 <file>` 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.