Rename open to stalled and autopilot to auto-confirm; add pushed state; declare idempotency; propose maturity and projects model (RBT-WP-0008)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Work Records / validate (push) Successful in 14s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 12:19:58 +02:00
parent f39d5cec3c
commit f5619e1a52
19 changed files with 429 additions and 105 deletions

View file

@ -0,0 +1,106 @@
# Runbook maturity, projects and idempotency
Status: **proposal for decision** (RBT-WP-0008-T01). Raised by Bernd on 2026-09-29.
## The problem
`exercise_status: unexercised | exercised <date> by <operator>` mixes three different things:
1. **What the runbook is like** (does it change state, can it be repeated, can it be undone).
2. **How much the runbook can be trusted** (has it been rehearsed, and where).
3. **What state an environment is in** (has this runbook been applied to *this* system).
A run that succeeded on one system says little about another system, so "exercised" is not a
property of the runbook alone. Environments differ, and applying a runbook to an environment is a
state of that environment, not of the runbook.
## Terminology survey
From general DevOps, SRE, configuration-management and ITIL practice, written from memory, not from
a source review. Please treat the names as candidates.
| Area | Terms in common use | Relevance here |
| --- | --- | --- |
| Automation maturity | manual, semi-automated (guided), automated; the "runbook automation ladder"; toil reduction (SRE) | our modes: manual, auto-confirm, later auto-pilot with execution |
| Rehearsal | game day, fire drill, disaster-recovery drill, dry run, rehearsal in staging, `--check` mode (Ansible), `plan` before `apply` (Terraform) | how a runbook earns trust without touching production |
| Readiness | production readiness review (PRR), operational readiness, definition of done | a maturity gate before a runbook is used on production |
| Repeatability | idempotent, convergent (declare desired state), re-runnable, resumable, reversible; drift; "converged" | idempotency is a *declared characteristic*, not a state |
| Change control (ITIL) | standard change (pre-approved because proven repeatable), normal change, change model | maps to "proven" runbooks that need no fresh review |
| Environments | environment / stage (dev, test, staging, production), target, inventory and group variables (Ansible), workspace (Terraform), context (kubectl), installation, instance | the thing runbooks are applied to |
| Decay | runbook rot, stale, drift between docs and system | maturity must expire when the runbook or the system changes |
## Proposal: three separate axes
### 1. Characteristics: declared by the author, static
- **Idempotency**: `idempotent | non-idempotent | conditional` (with a note). Declared per step; the
runbook's value is derived (all idempotent, or any non-idempotent, or unknown if a step is undeclared).
**Implemented 2026-09-29** in the v0.2 schema and validator. Values are words, not yes/no, because
YAML reads unquoted `yes`/`no` as booleans.
- **Reversibility**: already declared per step (`reversible`), with rollback text.
- **Effect class** (candidate): read-only, changes-state, destructive.
- Consequences to build: retry of a failed non-idempotent step should require the operator to confirm
that the earlier attempt did not partly apply; re-running a whole idempotent runbook against a
converged environment is safe and is the way to verify it.
### 2. Maturity: earned, independent of any environment, bound to a pack revision
Derived from run history, not typed in. A run records a digest of the pack revision it used.
| Level | Meaning (candidate names) |
| --- | --- |
| `draft` | valid against the spec, never run |
| `reviewed` | a second person has read it (recorded) |
| `rehearsed` | completed cleanly (no deviation) in a **rehearsal** environment |
| `proven` | completed cleanly in at least two distinct environments by at least two operators |
| `stale` | the pack changed since it was proven: maturity is bound to a revision and expires when it changes |
This replaces `exercise_status`. Deviations (overrides, waivers, push-through) never count towards
maturity, which matches the current rule for exercising.
### 3. Projects: environments the runbooks are applied to
A **project** groups runbooks that share one environment. It holds what they have in common and provides
it as datapoint defaults (like Ansible group variables), so a value such as the target host or tenant is
entered once.
```yaml
project: railiance01-identity
environment: production # rehearsal | staging | production (class, not free text)
datapoints: {tenant: coulomb, target_host: railiance01} # non-secret; never secrets
runbooks:
- {pack: nk.ssh-certificates}
- {pack: nk.openbao-operating-path, after: [nk.ssh-certificates]}
```
Project state is derived from the latest run of each member at the current pack revision:
| State (candidate) | Meaning |
| --- | --- |
| `planned` | no member applied yet |
| `applying` | a run is in progress, or some members are applied and others not (the "running" state) |
| `applied` | every member's latest run succeeded cleanly at the current revision |
| `stale` | a member's pack changed, or a run failed or was abandoned, since it was applied |
| `drifted` (later) | verification against the live system no longer matches |
A **rehearsal** project is where maturity evidence comes from. "Exercised" then has a precise meaning:
*applied successfully in a rehearsal project*. A production project is `applied`, not "exercised".
## Consequences for what exists
- 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.
- 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/`.
## Open questions
1. **Names.** *Project* is Bernd's word. Alternatives: *target*, *engagement*, *installation*, *rollout*. Is
*environment class* (`rehearsal|staging|production`) the right way to separate rehearsal from real?
2. **Maturity thresholds.** Is "two environments, two operators" the right bar for `proven`? Should `reviewed`
be part of the ladder?
3. **Where projects live**, and whether one runbook may belong to several projects (yes, one project per
environment, so a runbook applies to many).
4. **Retry policy** for non-idempotent steps, as described above.
5. **Shared run history**: committing a redacted run summary versus keeping records local.

View file

@ -154,7 +154,7 @@ A **run** has a mode. The mode belongs to the run, not to the pack.
| Mode | Who confirms | Checks, gates, preconditions | | Mode | Who confirms | Checks, gates, preconditions |
| --- | --- | --- | | --- | --- | --- |
| **manual** | the operator | shown with their state; the operator confirms each | | **manual** | the operator | shown with their state; the operator confirms each |
| **autopilot** | the engine, when every precondition is met | evaluated by the engine; confirmation is applied automatically | | **auto-confirm** | the engine, when every precondition is met | evaluated by the engine; confirmation is applied automatically |
### Manual mode: override ### Manual mode: override
@ -164,10 +164,10 @@ downstream risk (`downstream_risk` text declared on the step or gate, else a
generic warning), (2) require an explicit confirmation, and (3) record a generic warning), (2) require an explicit confirmation, and (3) record a
**deviation** in the run. **deviation** in the run.
### Autopilot mode: timeout and push-through ### Auto-confirm mode: timeout and push-through
- A step or gate may declare `timeout: {seconds: N, on_timeout: stop|push-through}`; - A step or gate may declare `timeout: {seconds: N, on_timeout: stop|push-through}`;
a run may set a default. Without a timeout, autopilot waits. a run may set a default. Without a timeout, auto-confirm waits.
- `stop` (default): on timeout the run stops and reports. - `stop` (default): on timeout the run stops and reports.
- `push-through`: on timeout the engine confirms the item, records a deviation, - `push-through`: on timeout the engine confirms the item, records a deviation,
and moves on, ignoring the problem. This gathers the most information about and moves on, ignoring the problem. This gathers the most information about
@ -176,10 +176,13 @@ generic warning), (2) require an explicit confirmation, and (3) record a
with an explicit, recorded pre-authorization naming those steps (and the pack's with an explicit, recorded pre-authorization naming those steps (and the pack's
`confirm_phrase`). Push-through does not bypass that rule. This is a safety `confirm_phrase`). Push-through does not bypass that rule. This is a safety
default, open to review. default, open to review.
- A step may declare `autopilot: manual-only` to forbid automation entirely. - A step may declare `auto_confirm: manual-only` to forbid automation entirely.
- Auto-confirm applies *confirmation* only. It does not run a step's own command; execution is a later, separate level (RBT-WP-0006).
### Deviations ### Deviations
An item passed over by push-through gets the distinct state `pushed` (steps, gates and datapoints): not done, not set, not an error.
Every override, waiver and push-through is a **deviation** in the run record: Every override, waiver and push-through is a **deviation** in the run record:
`kind` (`override|waiver|push-through`), `item` (step or gate id), `mode`, `kind` (`override|waiver|push-through`), `item` (step or gate id), `mode`,
`unmet` (what was not satisfied), `risk_acknowledged`, `reason` (required for `unmet` (what was not satisfied), `risk_acknowledged`, `reason` (required for
@ -201,6 +204,10 @@ declared in advance (`waivable` on a gate); an **override** is not.
## Later: dependencies between runbooks ## Later: dependencies between runbooks
**Idempotency** is a declared characteristic of a step: `idempotent: idempotent | non-idempotent | conditional`
(`conditional` needs an `idempotent_note`). Undeclared means unknown. The runbook's overall value is derived.
See `docs/maturity-and-projects.md`.
Not in v0.2. When a procedure needs another runbook (for example "OpenBao operating Not in v0.2. When a procedure needs another runbook (for example "OpenBao operating
path requires the SSH access pack"), packs will declare `requires_packs` with a path requires the SSH access pack"), packs will declare `requires_packs` with a
required result (exercised, or a named gate passed). Tracked in RBT-WP-0006. required result (exercised, or a named gate passed). Tracked in RBT-WP-0006.

View file

@ -27,17 +27,17 @@ CLI: `rtut run new|list|status|cmd|start|verify|done|fail|retry|skip|finish <pac
## Outcome, score and exercise status ## Outcome, score and exercise status
A run is **open** until finalized. `rtut run finalize` (or the UI's *Close this run*) records A run is unfinished (**stalled**) until finalized. `rtut run finalize` (or the UI's *Close this run*) records
an `outcome`: an `outcome`:
| Outcome | Rule | | Outcome | Rule |
| --- | --- | | --- | --- |
| `succeeded` | every step is `done` (skipped steps do not count) | | `succeeded` | every step is `done` (skipped steps do not count) |
| `failed` | at least one step has failed; otherwise abandon | | `failed` | at least one step has failed; otherwise abandon |
| `abandoned` | any time. An open run untouched for 7 days also counts as abandoned in the score | | `abandoned` | any time. An unfinished run untouched for 7 days also counts as abandoned in the score |
A finished run is read-only, except for feedback. The **score** of a runbook is derived from A finished run is read-only, except for feedback. The **score** of a runbook is derived from
its run records: tried, succeeded, failed, abandoned, open, plus a tally of feedback ratings its run records: tried, succeeded, failed, abandoned, stalled (unfinished), plus a tally of feedback ratings
(`rtut score <root>`; shown in the UI). Run records live in `.local/` of the machine that ran (`rtut score <root>`; shown in the UI). Run records live in `.local/` of the machine that ran
them, so the score is per machine until run histories are shared. them, so the score is per machine until run histories are shared.
@ -67,12 +67,12 @@ Implemented in `rtut/run2.py` (graph in `rtut/flow.py`). It adds to the v0.1 rec
| Field | Meaning | | Field | Meaning |
| --- | --- | | --- | --- |
| `mode` | `manual` or `autopilot` | | `mode` | `manual` or `auto-confirm` |
| `datapoints.<id>` | `state` nil, set, err or ok; `value`, `how` (initial, manual, produced, check, manual confirmation), `error` | | `datapoints.<id>` | `state` nil, set, err, ok or pushed; `value`, `how` (initial, manual, produced, check, manual confirmation), `error` |
| `steps.<id>` | as v0.1, plus `alternative` (chosen `one_of` branch), `authorized_by`, `pushed_through` | | `steps.<id>` | as v0.1 (`pushed` is an additional state), plus `alternative` (chosen `one_of` branch), `authorized_by` |
| `gates.<id>` | `state` open, passed, failed or waived; per-check `results`; `waiver` | | `gates.<id>` | `state` open, passed, failed, waived or pushed; per-check `results`; `waiver` or `pushed` |
| `deviations[]` | every override, waiver and push-through (see `docs/pack-spec-v0.2.md`) | | `deviations[]` | every override, waiver and push-through (see `docs/pack-spec-v0.2.md`) |
| `autopilot` | `preauthorized` irreversible steps, `authorized_by`, and `stopped` if a timeout stopped the run | | `auto-confirm` | `preauthorized` irreversible steps, `authorized_by`, and `stopped` if a timeout stopped the run |
Rules the engine enforces: Rules the engine enforces:
@ -88,13 +88,20 @@ Rules the engine enforces:
- **Override** (manual mode only): starting an item with unmet preconditions needs a reason and an acknowledged - **Override** (manual mode only): starting an item with unmet preconditions needs a reason and an acknowledged
downstream risk; it is recorded as a deviation. downstream risk; it is recorded as a deviation.
- **Waiver**: only for gates that declare `waivable`, with a reason; recorded as a deviation. - **Waiver**: only for gates that declare `waivable`, with a reason; recorded as a deviation.
- **Confirmation**: irreversible steps need the exact confirmation phrase at start. In autopilot they run only - **Confirmation**: irreversible steps need the exact confirmation phrase at start. In auto-confirm they run only
if the run pre-authorized them, and never if the step is `autopilot: manual-only`. if the run pre-authorized them, and never if the step is `auto_confirm: manual-only`.
- **Autopilot** (`autopilot_tick`): the engine applies confirmation when preconditions are met: it starts the - **Auto-confirm** (`auto-confirm_tick`): the engine applies confirmation when preconditions are met: it starts the
recommended item, runs its checks (manual checks are auto-confirmed and marked so) and completes it. It recommended item, runs its checks (manual checks are auto-confirmed and marked so) and completes it. It
never runs a step's own command; the work is done by the operator or, later, by opt-in execution. A step never runs a step's own command; the work is done by the operator or, later, by opt-in execution. A step
with `evidence` waits for the operator. If checks keep failing until the item's `timeout` (or the pack with `evidence` waits for the operator. If checks keep failing until the item's `timeout` (or the pack
default), the run stops, or with `on_timeout: push-through` the item is marked done with default), the run stops, or with `on_timeout: push-through` the item is marked done with
`pushed_through` and a deviation is recorded. state `pushed` and a deviation is recorded.
- **Deviations and exercise**: a run with any deviation or pushed-through step can be finalized but never exercises a pack. - **Deviations and exercise**: a run with any deviation or pushed-through step can be finalized but never exercises a pack.
`finalize(succeeded)` also needs every gate passed or waived. `finalize(succeeded)` also needs every gate passed or waived.
- **Pushed is its own state.** A step, gate or datapoint passed over by push-through is `pushed`: not `done`,
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.
- **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).

View file

@ -114,7 +114,7 @@ def main(argv: list[str] | None = None) -> int:
for p in _packs(a.root): for p in _packs(a.root):
if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1": if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1":
c = R.scorecard(p) c = R.scorecard(p)
print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\topen {c['open']}\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[{p.data['exercise_status']}]")
return 0 return 0
if a.cmd in ("list", "validate"): if a.cmd in ("list", "validate"):
packs = _packs(a.root) packs = _packs(a.root)

View file

@ -67,3 +67,17 @@ class Flow:
for a in self.ancestors(id_): for a in self.ancestors(id_):
refs += self.stages[a].get("requires", []) refs += self.stages[a].get("requires", [])
return [r for r in (self.resolve(x) for x in refs) if r] return [r for r in (self.resolve(x) for x in refs) if r]
def pack_idempotency(data: dict) -> str:
"""Derived characteristic of the whole runbook: idempotent, non-idempotent, conditional or unknown."""
fl = Flow(data)
vals = []
for s in fl.steps.values():
parts = s.get("one_of") or [s]
vals += [p.get("idempotent", s.get("idempotent", "unknown")) for p in parts]
if not vals or "unknown" in vals:
return "unknown"
if "non-idempotent" in vals:
return "non-idempotent"
return "conditional" if "conditional" in vals else "idempotent"

View file

@ -244,8 +244,6 @@ def exercise_update(run: Run, today: date | None = None) -> tuple[str, str]:
raise RunError("every step must be done (not skipped) to exercise the pack") raise RunError("every step must be done (not skipped) to exercise the pack")
if run.data.get("deviations"): if run.data.get("deviations"):
raise RunError("a run with deviations (overrides, waivers, push-through) cannot exercise the pack") raise RunError("a run with deviations (overrides, waivers, push-through) cannot exercise the pack")
if any(v.get("pushed_through") for v in run.data["steps"].values()):
raise RunError("a run with pushed-through steps cannot exercise the pack")
today = today or date.today() today = today or date.today()
old = run.pack.path.read_text() old = run.pack.path.read_text()
new = STATUS_LINE.sub(f"exercise_status: exercised {today.isoformat()} by {run.data['operator']}", new = STATUS_LINE.sub(f"exercise_status: exercised {today.isoformat()} by {run.data['operator']}",
@ -346,8 +344,9 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
raise RunError("a run succeeds only when every step is done (skipped steps do not count)") raise RunError("a run succeeds only when every step is done (skipped steps do not count)")
if outcome == "succeeded" and any(g["state"] not in ("passed", "waived") for g in run.data.get("gates", {}).values()): if outcome == "succeeded" and any(g["state"] not in ("passed", "waived") for g in run.data.get("gates", {}).values()):
raise RunError("a run succeeds only when every gate has passed or been waived") raise RunError("a run succeeds only when every gate has passed or been waived")
if outcome == "failed" and "failed" not in states: gstates = [g["state"] for g in run.data.get("gates", {}).values()]
raise RunError("a run can be finished as failed only when a step has failed; otherwise abandon it") if outcome == "failed" and not ({"failed", "pushed"} & set(states) or {"failed", "pushed"} & set(gstates)):
raise RunError("a run can be finished as failed only when a step or gate has failed or was pushed through; otherwise abandon it")
run.data["outcome"] = outcome run.data["outcome"] = outcome
run.data["finished"] = _now() run.data["finished"] = _now()
if note.strip(): if note.strip():
@ -356,17 +355,17 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = STALE_DAYS) -> str: def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = STALE_DAYS) -> str:
"""finished outcome, or 'open', or 'abandoned' for an open run untouched for stale_days.""" """finished outcome, or 'stalled' (unfinished), or 'abandoned' for an unfinished run untouched for stale_days."""
if run.data.get("outcome"): if run.data.get("outcome"):
return run.data["outcome"] return run.data["outcome"]
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
last = datetime.fromisoformat(run.data.get("updated") or run.data["created"]) last = datetime.fromisoformat(run.data.get("updated") or run.data["created"])
return "abandoned" if (now - last).days >= stale_days else "open" return "abandoned" if (now - last).days >= stale_days else "stalled"
def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict: def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict:
"""How often a runbook was tried and how it went, derived from its run records.""" """How often a runbook was tried and how it went, derived from its run records."""
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "open": 0, card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "stalled": 0,
"feedback": {"good": 0, "neutral": 0, "poor": 0}} "feedback": {"good": 0, "neutral": 0, "poor": 0}}
for rid in list_runs(pack): for rid in list_runs(pack):
r = load(pack, rid) r = load(pack, rid)

View file

@ -1,7 +1,7 @@
"""Runs of runbook-pack/v0.2 packs (record runbook-run/v0.2). """Runs of runbook-pack/v0.2 packs (record runbook-run/v0.2).
Adds to the v0.1 run: datapoint states (nil/set/err/ok), a dependency graph with a recommended Adds to the v0.1 run: datapoint states (nil/set/err/ok), a dependency graph with a recommended
next action, alternatives, QA gates with waivers, manual and autopilot modes with timeouts, and next action, alternatives, QA gates with waivers, manual and auto-confirm modes with timeouts, and
a deviation record. Shared helpers (feedback, finalize, captures, score) live in run.py. a deviation record. Shared helpers (feedback, finalize, captures, score) live in run.py.
""" """
from __future__ import annotations from __future__ import annotations
@ -18,8 +18,10 @@ from .run import Run, RunError, _clean, _now, runs_dir # noqa: F401 (re-export
DEFAULT_PHRASE = "I understand this cannot be undone" DEFAULT_PHRASE = "I understand this cannot be undone"
VERIFY_TIMEOUT = 60 VERIFY_TIMEOUT = 60
DONE_STEP = ("done", "skipped") # "pushed" is its own state: the item was passed over by auto-confirm push-through without being verified.
DONE_GATE = ("passed", "waived") # It lets dependents proceed but is neither done nor an error, and it is tracked separately.
DONE_STEP = ("done", "skipped", "pushed")
DONE_GATE = ("passed", "waived", "pushed")
def flow(run: Run) -> Flow: def flow(run: Run) -> Flow:
@ -85,8 +87,8 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
preauthorize: list[str] | None = None) -> Run: preauthorize: list[str] | None = None) -> Run:
if not pack.ok or pack.data.get("engine") != "native" or pack.spec != "runbook-pack/v0.2": if not pack.ok or pack.data.get("engine") != "native" or pack.spec != "runbook-pack/v0.2":
raise RunError("only valid native runbook-pack/v0.2 packs can be run here") raise RunError("only valid native runbook-pack/v0.2 packs can be run here")
if mode not in ("manual", "autopilot"): if mode not in ("manual", "auto-confirm"):
raise RunError("mode must be manual or autopilot") raise RunError("mode must be manual or auto-confirm")
operator = _clean(operator.strip(), "operator") operator = _clean(operator.strip(), "operator")
if not re.fullmatch(r"\S+", operator): if not re.fullmatch(r"\S+", operator):
raise RunError("operator must be one word (name or handle)") raise RunError("operator must be one word (name or handle)")
@ -102,7 +104,7 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
"spec": "runbook-run/v0.2", "run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6], "spec": "runbook-run/v0.2", "run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"pack_id": pack.id, "pack_path": str(pack.path), "operator": operator, "created": _now(), "pack_id": pack.id, "pack_path": str(pack.path), "operator": operator, "created": _now(),
"mode": mode, "params": {}, "deviations": [], "mode": mode, "params": {}, "deviations": [],
"autopilot": {"preauthorized": pre, "authorized_by": operator if pre else None}, "auto_confirm": {"preauthorized": pre, "authorized_by": operator if pre else None},
"datapoints": {i: {"state": "nil"} for i in fl.datapoints}, "datapoints": {i: {"state": "nil"} for i in fl.datapoints},
"steps": {i: {"state": "todo"} for i in fl.steps}, "steps": {i: {"state": "todo"} for i in fl.steps},
"gates": {i: {"state": "open", "results": {}} for i in fl.gates}, "gates": {i: {"state": "open", "results": {}} for i in fl.gates},
@ -188,7 +190,7 @@ def unmet(run: Run, id_: str) -> list[str]:
if fl.kind(id_) == "step": if fl.kind(id_) == "step":
for dp in fl.steps[id_].get("uses", []): for dp in fl.steps[id_].get("uses", []):
st = run.data["datapoints"][dp]["state"] st = run.data["datapoints"][dp]["state"]
if st not in ("set", "ok"): if st not in ("set", "ok", "pushed"):
out.append(f"datapoint {dp} is {st}") out.append(f"datapoint {dp} is {st}")
elif fl.kind(id_) == "gate": elif fl.kind(id_) == "gate":
stage = fl.parent[id_] stage = fl.parent[id_]
@ -266,9 +268,9 @@ def start(run: Run, step_id: str, alt: str | None = None, phrase: str | None = N
raise RunError(f"step {step_id} has no alternatives") raise RunError(f"step {step_id} has no alternatives")
eff = effective_step(run.pack, fl, step_id, st.get("alternative")) eff = effective_step(run.pack, fl, step_id, st.get("alternative"))
if not eff.get("reversible", True): # irreversible: the typed phrase is enforced here if not eff.get("reversible", True): # irreversible: the typed phrase is enforced here
pre = run.data["autopilot"]["preauthorized"] pre = run.data["auto_confirm"]["preauthorized"]
if step_id in pre and run.data["mode"] == "autopilot": if step_id in pre and run.data["mode"] == "auto-confirm":
st["authorized_by"] = run.data["autopilot"]["authorized_by"] st["authorized_by"] = run.data["auto_confirm"]["authorized_by"]
elif (phrase or "") != phrase_for(run, step_id): elif (phrase or "") != phrase_for(run, step_id):
raise RunError(f"step {step_id} is irreversible: type the confirmation phrase exactly: " raise RunError(f"step {step_id} is irreversible: type the confirmation phrase exactly: "
f"{phrase_for(run, step_id)!r}") f"{phrase_for(run, step_id)!r}")
@ -298,7 +300,10 @@ def _check_result(run: Run, ck: dict, confirm: bool, manual_ok: bool | None, aut
elif exp in ("exit-0", "output-contains"): elif exp in ("exit-0", "output-contains"):
if not confirm: if not confirm:
raise RunError("running a check command needs operator confirmation") raise RunError("running a check command needs operator confirmation")
cmd = render(ck["command"], run.data["params"], run.pack) try:
cmd = render(ck["command"], run.data["params"], run.pack)
except (KeyError, ValueError) as e: # e.g. a datapoint that was pushed through has no value
return {"passed": False, "kind": exp, "parts": {"command": False, "error": str(e)}, "at": _now()}
t0 = time.monotonic() t0 = time.monotonic()
try: try:
cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=VERIFY_TIMEOUT, cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=VERIFY_TIMEOUT,
@ -428,7 +433,7 @@ def waive(run: Run, gate_id: str, reason: str) -> None:
run.save() run.save()
# ---- autopilot ---------------------------------------------------------------------------------------------------------- # ---- auto-confirm ----------------------------------------------------------------------------------------------------------
def _timeout_for(run: Run, id_: str) -> dict | None: def _timeout_for(run: Run, id_: str) -> dict | None:
return flow(run).node(id_).get("timeout") or run.pack.data.get("timeout") return flow(run).node(id_).get("timeout") or run.pack.data.get("timeout")
@ -439,18 +444,18 @@ def _elapsed(started: str, now: datetime) -> float:
def _stop(run: Run, item: str, why: str) -> dict: def _stop(run: Run, item: str, why: str) -> dict:
run.data["autopilot"]["stopped"] = {"item": item, "reason": why, "at": _now()} run.data["auto_confirm"]["stopped"] = {"item": item, "reason": why, "at": _now()}
run.save() run.save()
return {"action": "stopped", "item": item, "detail": why} return {"action": "stopped", "item": item, "detail": why}
def autopilot_tick(run: Run, now: datetime | None = None) -> dict: def auto_confirm_tick(run: Run, now: datetime | None = None) -> dict:
"""One autopilot decision. Confirmation is applied when preconditions are met; a timeout stops """One auto-confirm decision. Confirmation is applied when preconditions are met; a timeout stops
the run or pushes through, recording a deviation. The engine never runs a step's own command.""" the run or pushes through, recording a deviation. The engine never runs a step's own command."""
_writable(run) _writable(run)
if run.data["mode"] != "autopilot": if run.data["mode"] != "auto-confirm":
raise RunError("autopilot_tick needs an autopilot run") raise RunError("auto_confirm_tick needs an auto-confirm run")
ap = run.data["autopilot"] ap = run.data["auto_confirm"]
if ap.get("stopped"): if ap.get("stopped"):
return {"action": "stopped", "item": ap["stopped"]["item"], "detail": ap["stopped"]["reason"]} return {"action": "stopped", "item": ap["stopped"]["item"], "detail": ap["stopped"]["reason"]}
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
@ -463,8 +468,8 @@ def autopilot_tick(run: Run, now: datetime | None = None) -> dict:
first = na["blocked"][0] first = na["blocked"][0]
return _stop(run, first["id"], "nothing can advance: " + "; ".join(first["unmet"])) return _stop(run, first["id"], "nothing can advance: " + "; ".join(first["unmet"]))
if fl.kind(item) == "step": if fl.kind(item) == "step":
return _autopilot_step(run, item, now) return _auto_confirm_step(run, item, now)
return _autopilot_gate(run, item, now) return _auto_confirm_gate(run, item, now)
def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict: def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict:
@ -473,17 +478,17 @@ def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict:
return {"action": "push-through", "item": item, "detail": detail} return {"action": "push-through", "item": item, "detail": detail}
def _autopilot_step(run: Run, sid: str, now: datetime) -> dict: def _auto_confirm_step(run: Run, sid: str, now: datetime) -> dict:
fl = flow(run) fl = flow(run)
s = fl.steps[sid] s = fl.steps[sid]
st = run.data["steps"][sid] st = run.data["steps"][sid]
if s.get("autopilot") == "manual-only": if s.get("auto_confirm") == "manual-only":
return {"action": "awaiting-operator", "item": sid, "detail": "step is manual-only"} return {"action": "awaiting-operator", "item": sid, "detail": "step is manual-only"}
if st["state"] == "failed": if st["state"] == "failed":
return {"action": "awaiting-operator", "item": sid, "detail": "step failed; retry or stop"} return {"action": "awaiting-operator", "item": sid, "detail": "step failed; retry or stop"}
alt = st.get("alternative") or recommended_alt(run, sid) alt = st.get("alternative") or recommended_alt(run, sid)
eff = effective_step(run.pack, fl, sid, alt) eff = effective_step(run.pack, fl, sid, alt)
if not eff.get("reversible", True) and sid not in run.data["autopilot"]["preauthorized"]: if not eff.get("reversible", True) and sid not in run.data["auto_confirm"]["preauthorized"]:
return {"action": "awaiting-operator", "item": sid, "detail": "irreversible and not pre-authorized"} return {"action": "awaiting-operator", "item": sid, "detail": "irreversible and not pre-authorized"}
if s.get("one_of") and not alt: if s.get("one_of") and not alt:
return {"action": "awaiting-operator", "item": sid, "detail": "no alternative is recommended; the operator must choose"} return {"action": "awaiting-operator", "item": sid, "detail": "no alternative is recommended; the operator must choose"}
@ -503,19 +508,20 @@ def _autopilot_step(run: Run, sid: str, now: datetime) -> dict:
if cfg and _elapsed(st["started"], now) >= cfg["seconds"]: if cfg and _elapsed(st["started"], now) >= cfg["seconds"]:
out = _on_timeout(run, sid, cfg, "verification did not pass") out = _on_timeout(run, sid, cfg, "verification did not pass")
if out["action"] == "push-through": if out["action"] == "push-through":
st.update(state="done", finished=_now(), pushed_through=True) st.update(state="pushed", finished=_now())
_deviate(run, "push-through", sid, ["verification did not pass"], _deviate(run, "push-through", sid, ["verification did not pass"],
f"autopilot timeout after {cfg['seconds']}s", _risk_text(run, sid), False) f"auto-confirm timeout after {cfg['seconds']}s", _risk_text(run, sid), False)
for dp in s.get("produces", []): for dp in s.get("produces", []):
rec = run.data["datapoints"].get(dp) rec = run.data["datapoints"].get(dp)
if rec and rec["state"] == "nil": if rec and rec["state"] in ("nil", "err"):
rec.update(state="err", error="pushed through without verification", at=_now()) rec.pop("error", None)
rec.update(state="pushed", how="push-through", at=_now())
run.save() run.save()
return out return out
return {"action": "waiting", "item": sid, "detail": "verification did not pass yet"} return {"action": "waiting", "item": sid, "detail": "verification did not pass yet"}
def _autopilot_gate(run: Run, gid: str, now: datetime) -> dict: def _auto_confirm_gate(run: Run, gid: str, now: datetime) -> dict:
g = flow(run).gates[gid] g = flow(run).gates[gid]
gs = run.data["gates"][gid] gs = run.data["gates"][gid]
gs.setdefault("started", _now()) gs.setdefault("started", _now())
@ -531,9 +537,9 @@ def _autopilot_gate(run: Run, gid: str, now: datetime) -> dict:
out = _on_timeout(run, gid, cfg, "gate did not pass") out = _on_timeout(run, gid, cfg, "gate did not pass")
if out["action"] == "push-through": if out["action"] == "push-through":
failed = [f"check {i} not passed" for i, r in gs["results"].items() if not r["passed"]] failed = [f"check {i} not passed" for i, r in gs["results"].items() if not r["passed"]]
gs.update(state="waived", waiver={"by_actor": "autopilot", "who": run.data["operator"], gs.update(state="pushed", pushed={"who": run.data["operator"],
"reason": f"push-through after {cfg['seconds']}s", "at": _now()}) "reason": f"push-through after {cfg['seconds']}s", "at": _now()})
_deviate(run, "push-through", gid, failed, f"autopilot timeout after {cfg['seconds']}s", _risk_text(run, gid), False) _deviate(run, "push-through", gid, failed, f"auto-confirm timeout after {cfg['seconds']}s", _risk_text(run, gid), False)
run.save() run.save()
return out return out
return {"action": "waiting", "item": gid, "detail": "gate did not pass yet"} return {"action": "waiting", "item": gid, "detail": "gate did not pass yet"}

View file

@ -47,7 +47,7 @@ SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor",
def score_line(c: dict) -> str: def score_line(c: dict) -> str:
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · " return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
f"abandoned {c['abandoned']} · open {c['open']}") f"abandoned {c['abandoned']} · stalled {c['stalled']}")
class App: class App:
@ -147,7 +147,7 @@ class App:
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this run?')\">Abandon run</button>") btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this run?')\">Abandon run</button>")
return (f"<div id=close class=step><h2>Close this run</h2>" return (f"<div id=close class=step><h2>Close this run</h2>"
"<p class=note>Finishing records the outcome in the pack's score. A finished run is read-only. " "<p class=note>Finishing records the outcome in the pack's score. A finished run is read-only. "
"Runs left open for a week count as abandoned.</p>" "Unfinished runs count as stalled, and as abandoned after a week.</p>"
f"<form method=post action='/run/{i}/{e(rid)}/finalize'>{self.hidden()}" f"<form method=post action='/run/{i}/{e(rid)}/finalize'>{self.hidden()}"
"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>" "<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>"
f"<p class=row>{' '.join(btns)}</p></form></div>") f"<p class=row>{' '.join(btns)}</p></form></div>")

View file

@ -128,6 +128,9 @@ def _check_refs(m: _Model, data: dict) -> None:
for v in vals: for v in vals:
if v not in dps[k]["values"]: if v not in dps[k]["values"]:
m.errs.append(f"step {pid}: recommended_when {k}={v} not in values") m.errs.append(f"step {pid}: recommended_when {k}={v} not in values")
for part, pid in parts:
if part.get("idempotent", node.get("idempotent")) == "conditional" and not (part.get("idempotent_note") or node.get("idempotent_note")):
m.errs.append(f"step {pid}: idempotent: conditional needs an idempotent_note saying when")
if node.get("one_of") and node.get("command"): if node.get("one_of") and node.get("command"):
m.errs.append(f"step {id_}: a step with one_of must not also have its own command") m.errs.append(f"step {id_}: a step with one_of must not also have its own command")
elif kind == "gate": elif kind == "gate":

View file

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

View file

@ -41,6 +41,7 @@ stages:
goal: The lamp is lit and the weather is known before dark. goal: The lamp is lit and the weather is known before dark.
steps: # no requires among these: they are parallel; list order is the recommended order steps: # no requires among these: they are parallel; list order is the recommended order
- id: read-the-sign - id: read-the-sign
idempotent: idempotent
title: Read the poem over the door title: Read the poem over the door
actor: keeper actor: keeper
owner: the-lighthouse owner: the-lighthouse
@ -48,6 +49,7 @@ stages:
reversible: true reversible: true
verify: {done_when: You read the poem, expect: manual} verify: {done_when: You read the poem, expect: manual}
- id: light-lamp - id: light-lamp
idempotent: idempotent
title: Light the lamp title: Light the lamp
actor: keeper actor: keeper
owner: the-lighthouse owner: the-lighthouse
@ -58,6 +60,7 @@ stages:
downstream_risk: Without a lit lamp the night watch has no purpose and ships are not warned. downstream_risk: Without a lit lamp the night watch has no purpose and ships are not warned.
verify: {done_when: The lamp is lit, expect: manual} verify: {done_when: The lamp is lit, expect: manual}
- id: read-the-sky - id: read-the-sky
idempotent: idempotent
title: Read the sky title: Read the sky
actor: keeper actor: keeper
owner: the-lighthouse owner: the-lighthouse
@ -83,6 +86,7 @@ stages:
requires: [gate:lamp-check] requires: [gate:lamp-check]
steps: steps:
- id: warn - id: warn
idempotent: idempotent
title: Warn the ships title: Warn the ships
actor: keeper actor: keeper
owner: the-lighthouse owner: the-lighthouse
@ -107,6 +111,8 @@ stages:
reversible: true reversible: true
verify: {done_when: The door is barred, expect: manual} verify: {done_when: The door is barred, expect: manual}
- id: write-logbook - id: write-logbook
idempotent: non-idempotent
idempotent_note: Appends a line every time it runs; a second run leaves a duplicate entry.
title: Write tonight's entry title: Write tonight's entry
actor: keeper actor: keeper
owner: the-lighthouse owner: the-lighthouse
@ -124,8 +130,10 @@ stages:
requires: [night] requires: [night]
steps: steps:
- id: close-log - id: close-log
idempotent: conditional
idempotent_note: Safe to repeat only while the log file exists; a second run fails on the read.
title: Close the log for good title: Close the log for good
autopilot: manual-only # never confirmed by the engine on its own auto_confirm: manual-only # never confirmed by the engine on its own
actor: harbourmaster actor: harbourmaster
owner: the-lighthouse owner: the-lighthouse
uses: [logdir] uses: [logdir]

View file

@ -48,7 +48,7 @@
}, },
"timeout": { "timeout": {
"$ref": "#/$defs/timeout", "$ref": "#/$defs/timeout",
"description": "Default for autopilot runs." "description": "Default for auto-confirm runs."
}, },
"legacy": { "legacy": {
"type": "object", "type": "object",
@ -341,6 +341,16 @@
"type": "string" "type": "string"
} }
} }
},
"idempotent": {
"enum": [
"idempotent",
"non-idempotent",
"conditional"
]
},
"idempotent_note": {
"type": "string"
} }
} }
}, },
@ -417,18 +427,30 @@
"$ref": "#/$defs/alternative" "$ref": "#/$defs/alternative"
} }
}, },
"autopilot": { "timeout": {
"$ref": "#/$defs/timeout"
},
"downstream_risk": {
"type": "string"
},
"auto_confirm": {
"enum": [ "enum": [
"allowed", "allowed",
"manual-only" "manual-only"
], ],
"default": "allowed" "default": "allowed"
}, },
"timeout": { "idempotent": {
"$ref": "#/$defs/timeout" "enum": [
"idempotent",
"non-idempotent",
"conditional"
],
"description": "Is re-running this step after a partial or complete earlier attempt safe? Omit if unknown. Values are words, never yes/no: YAML reads unquoted yes/no as booleans."
}, },
"downstream_risk": { "idempotent_note": {
"type": "string" "type": "string",
"description": "Required when idempotent is conditional: under what condition."
} }
}, },
"anyOf": [ "anyOf": [

View file

@ -196,25 +196,25 @@ def test_irreversible_step_needs_the_typed_phrase(tmp_path):
def test_preauthorization_only_for_irreversible_steps(tmp_path): def test_preauthorization_only_for_irreversible_steps(tmp_path):
with pytest.raises(R.RunError, match="only irreversible"): with pytest.raises(R.RunError, match="only irreversible"):
new(tmp_path, mode="autopilot", preauthorize=["light-lamp"]) new(tmp_path, mode="auto-confirm", preauthorize=["light-lamp"])
# ---- autopilot ------------------------------------------------------------------------------------------------ # ---- auto-confirm ------------------------------------------------------------------------------------------------
def drive(r, ticks=60, now=None, operator=None): def drive(r, ticks=60, now=None, operator=None):
log = [] log = []
for _ in range(ticks): for _ in range(ticks):
out = R.autopilot_tick(r, now) out = R.auto_confirm_tick(r, now)
log.append(out) log.append(out)
if out["action"] in ("finished", "stopped", "awaiting-operator", "waiting"): if out["action"] in ("finished", "stopped", "awaiting-operator", "waiting"):
break break
if out["action"] == "started" and operator: if out["action"] == "started" and operator:
operator(out["item"]) # the operator runs the step's command while autopilot confirms operator(out["item"]) # the operator runs the step's command while auto-confirm confirms
return log return log
def test_autopilot_confirms_when_preconditions_are_met_and_waits_for_the_operator_on_manual_only(tmp_path): def test_auto_confirm_confirms_when_preconditions_are_met_and_waits_for_the_operator_on_manual_only(tmp_path):
r = new(tmp_path, mode="autopilot") r = new(tmp_path, mode="auto-confirm")
def operator(sid): def operator(sid):
cmd = R.command_for(r, sid) cmd = R.command_for(r, sid)
@ -232,37 +232,37 @@ def test_autopilot_confirms_when_preconditions_are_met_and_waits_for_the_operato
R.start(r, "close-log", phrase=PHRASE) # the operator takes over the last step R.start(r, "close-log", phrase=PHRASE) # the operator takes over the last step
subprocess.run(R.command_for(r, "close-log"), shell=True, check=True, capture_output=True) subprocess.run(R.command_for(r, "close-log"), shell=True, check=True, capture_output=True)
R.verify(r, "close-log", confirm=True); R.complete(r, "close-log") R.verify(r, "close-log", confirm=True); R.complete(r, "close-log")
assert R.autopilot_tick(r)["action"] == "completed" # gate night-on-record assert R.auto_confirm_tick(r)["action"] == "completed" # gate night-on-record
assert R.autopilot_tick(r)["action"] == "finished" assert R.auto_confirm_tick(r)["action"] == "finished"
R1.finalize(r, "succeeded") R1.finalize(r, "succeeded")
assert r.data["outcome"] == "succeeded" and not r.data["deviations"] assert r.data["outcome"] == "succeeded" and not r.data["deviations"]
def test_autopilot_timeout_stop(tmp_path): def test_auto_confirm_timeout_stop(tmp_path):
def m(d): def m(d):
d["timeout"] = {"seconds": 30, "on_timeout": "stop"} d["timeout"] = {"seconds": 30, "on_timeout": "stop"}
r = new(tmp_path, m, mode="autopilot") r = new(tmp_path, m, mode="auto-confirm")
log = drive(r) # write-logbook's file check cannot pass: nobody ran its command log = drive(r) # write-logbook's file check cannot pass: nobody ran its command
assert log[-1]["action"] == "waiting" and log[-1]["item"] == "write-logbook" assert log[-1]["action"] == "waiting" and log[-1]["item"] == "write-logbook"
late = datetime.now(timezone.utc) + timedelta(seconds=31) late = datetime.now(timezone.utc) + timedelta(seconds=31)
out = R.autopilot_tick(r, late) out = R.auto_confirm_tick(r, late)
assert out["action"] == "stopped" and "timed out after 30s" in out["detail"] assert out["action"] == "stopped" and "timed out after 30s" in out["detail"]
assert R.autopilot_tick(r)["action"] == "stopped" # stays stopped assert R.auto_confirm_tick(r)["action"] == "stopped" # stays stopped
assert r.data["deviations"] == [] # stopping is not a deviation assert r.data["deviations"] == [] # stopping is not a deviation
def test_autopilot_push_through_records_deviation_and_moves_on(tmp_path): def test_auto_confirm_push_through_records_deviation_and_moves_on(tmp_path):
def m(d): def m(d):
d["timeout"] = {"seconds": 30, "on_timeout": "push-through"} d["timeout"] = {"seconds": 30, "on_timeout": "push-through"}
r = new(tmp_path, m, mode="autopilot") r = new(tmp_path, m, mode="auto-confirm")
drive(r) drive(r)
late = datetime.now(timezone.utc) + timedelta(seconds=31) late = datetime.now(timezone.utc) + timedelta(seconds=31)
out = R.autopilot_tick(r, late) out = R.auto_confirm_tick(r, late)
assert out["action"] == "push-through" and out["item"] == "write-logbook" assert out["action"] == "push-through" and out["item"] == "write-logbook"
assert r.data["steps"]["write-logbook"]["state"] == "done" and r.data["steps"]["write-logbook"]["pushed_through"] assert r.data["steps"]["write-logbook"]["state"] == "pushed" # its own state: not done, not failed
dev = r.data["deviations"][0] dev = r.data["deviations"][0]
assert dev["kind"] == "push-through" and dev["mode"] == "autopilot" and dev["risk_acknowledged"] is False assert dev["kind"] == "push-through" and dev["mode"] == "auto-confirm" and dev["risk_acknowledged"] is False
nxt = R.autopilot_tick(r, late) # continues to the manual-only step nxt = R.auto_confirm_tick(r, late) # continues to the manual-only step
assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator" assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator"
with pytest.raises(R1.RunError, match="deviations"): with pytest.raises(R1.RunError, match="deviations"):
for k in r.data["steps"]: for k in r.data["steps"]:
@ -270,37 +270,37 @@ def test_autopilot_push_through_records_deviation_and_moves_on(tmp_path):
R1.exercise_update(r) R1.exercise_update(r)
def test_autopilot_gate_push_through_and_stop(tmp_path): def test_auto_confirm_gate_push_through_and_stop(tmp_path):
def m(d): def m(d):
d["stages"][0]["gates"][0]["checks"].append({"done_when": "always fails", "command": "false", "expect": "exit-0"}) d["stages"][0]["gates"][0]["checks"].append({"done_when": "always fails", "command": "false", "expect": "exit-0"})
d["stages"][0]["gates"][0]["timeout"] = {"seconds": 10, "on_timeout": "push-through"} d["stages"][0]["gates"][0]["timeout"] = {"seconds": 10, "on_timeout": "push-through"}
r = new(tmp_path, m, mode="autopilot") r = new(tmp_path, m, mode="auto-confirm")
drive(r) drive(r)
assert r.data["gates"]["lamp-check"]["state"] == "failed" # all checks evaluated, one failing assert r.data["gates"]["lamp-check"]["state"] == "failed" # all checks evaluated, one failing
out = R.autopilot_tick(r, datetime.now(timezone.utc) + timedelta(seconds=11)) out = R.auto_confirm_tick(r, datetime.now(timezone.utc) + timedelta(seconds=11))
assert out["action"] == "push-through" and r.data["gates"]["lamp-check"]["state"] == "waived" assert out["action"] == "push-through" and r.data["gates"]["lamp-check"]["state"] == "pushed" # not "waived": nobody approved it
assert r.data["deviations"][0]["item"] == "lamp-check" and r.data["deviations"][0]["kind"] == "push-through" assert r.data["deviations"][0]["item"] == "lamp-check" and r.data["deviations"][0]["kind"] == "push-through"
def test_autopilot_never_confirms_irreversible_steps_unless_preauthorized(tmp_path): def test_auto_confirm_never_confirms_irreversible_steps_unless_preauthorized(tmp_path):
def m(d): def m(d):
for st in d["stages"]: for st in d["stages"]:
for s in st.get("steps", []): for s in st.get("steps", []):
s.pop("autopilot", None) # allow automation of close-log s.pop("auto_confirm", None) # allow automation of close-log
r = new(tmp_path, m, mode="autopilot") r = new(tmp_path, m, mode="auto-confirm")
drive(r, operator=lambda sid: (subprocess.run(R.command_for(r, sid), shell=True, check=True, capture_output=True) if R.command_for(r, sid) else None)) drive(r, operator=lambda sid: (subprocess.run(R.command_for(r, sid), shell=True, check=True, capture_output=True) if R.command_for(r, sid) else None))
last = R.autopilot_tick(r) last = R.auto_confirm_tick(r)
assert last["action"] == "awaiting-operator" and "not pre-authorized" in last["detail"] assert last["action"] == "awaiting-operator" and "not pre-authorized" in last["detail"]
r2 = new(tmp_path, m, mode="autopilot", preauthorize=["close-log"]) r2 = new(tmp_path, m, mode="auto-confirm", preauthorize=["close-log"])
drive(r2, operator=lambda sid: (subprocess.run(R.command_for(r2, sid), shell=True, check=True, capture_output=True) if R.command_for(r2, sid) else None)) drive(r2, operator=lambda sid: (subprocess.run(R.command_for(r2, sid), shell=True, check=True, capture_output=True) if R.command_for(r2, sid) else None))
assert r2.data["steps"]["close-log"]["state"] in ("running", "done") assert r2.data["steps"]["close-log"]["state"] in ("running", "done")
assert r2.data["steps"]["close-log"]["authorized_by"] == "tester" assert r2.data["steps"]["close-log"]["authorized_by"] == "tester"
def test_manual_mode_rejects_autopilot_and_autopilot_rejects_overrides(tmp_path): def test_manual_mode_rejects_auto_confirm_and_auto_confirm_rejects_overrides(tmp_path):
with pytest.raises(R.RunError, match="autopilot run"): with pytest.raises(R.RunError, match="auto-confirm run"):
R.autopilot_tick(new(tmp_path)) R.auto_confirm_tick(new(tmp_path))
r = new(tmp_path, mode="autopilot") r = new(tmp_path, mode="auto-confirm")
with pytest.raises(R.RunError, match="manual mode"): with pytest.raises(R.RunError, match="manual mode"):
R.start(r, "warn", alt="sound-horn", override_reason="x", acknowledge_risk=True) R.start(r, "warn", alt="sound-horn", override_reason="x", acknowledge_risk=True)
@ -314,3 +314,32 @@ def test_finished_v02_run_is_read_only_and_scores_count_it(tmp_path):
R.set_datapoint(r, "weather", "clear") R.set_datapoint(r, "weather", "clear")
assert R1.scorecard(r.pack)["abandoned"] == 1 assert R1.scorecard(r.pack)["abandoned"] == 1
assert R1.load(r.pack, r.data["run_id"]).data["spec"] == "runbook-run/v0.2" # reloads through the shared loader assert R1.load(r.pack, r.data["run_id"]).data["spec"] == "runbook-run/v0.2" # reloads through the shared loader
def test_pushed_items_are_a_distinct_state_that_lets_the_run_continue_but_never_succeed(tmp_path):
def m(d):
d["timeout"] = {"seconds": 5, "on_timeout": "push-through"}
light = next(s for st in d["stages"] for s in st.get("steps", []) if s["id"] == "light-lamp")
light["verify"] = {"done_when": "never true here", "command": "false", "expect": "exit-0"}
d["stages"][0]["gates"][0]["timeout"] = {"seconds": 5, "on_timeout": "push-through"} # the gate's own timeout wins
r = new(tmp_path, m, mode="auto-confirm")
late = datetime.now(timezone.utc) + timedelta(seconds=6)
seen = []
for _ in range(40):
out = R.auto_confirm_tick(r, late)
seen.append(out["action"])
if out["action"] in ("stopped", "awaiting-operator", "finished"):
break
late = late + timedelta(seconds=6)
assert r.data["steps"]["light-lamp"]["state"] == "pushed"
dp = r.data["datapoints"]["lamp_lit"]
assert dp["state"] == "pushed" and "value" not in dp and "error" not in dp # not set, not err
assert "lamp_lit" not in r.data["params"] # never usable as a value
assert R.is_complete(r, "light-lamp") # dependents may proceed
assert r.data["gates"]["lamp-check"]["state"] == "pushed" # datapoints_ok needs 'ok', so the gate was pushed too
kinds = [d["kind"] for d in r.data["deviations"]]
assert kinds.count("push-through") >= 2 and "override" not in kinds
with pytest.raises(R1.RunError, match="succeeds only when every step is done"):
R1.finalize(r, "succeeded")
R1.finalize(r, "failed", "pushed through to collect problems") # a clean-completion failure, finishable as failed
assert r.data["outcome"] == "failed"

View file

@ -54,12 +54,12 @@ def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded"); R.add_feedback(ok, "good") ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded"); R.add_feedback(ok, "good")
bad = R.create(pk, "b"); R.start(bad, "read-the-sign"); R.fail(bad, "read-the-sign", "x"); R.finalize(bad, "failed") bad = R.create(pk, "b"); R.start(bad, "read-the-sign"); R.fail(bad, "read-the-sign", "x"); R.finalize(bad, "failed")
gone = R.create(pk, "c"); R.finalize(gone, "abandoned") gone = R.create(pk, "c"); R.finalize(gone, "abandoned")
open_ = R.create(pk, "d") stalled = R.create(pk, "d")
stale = R.create(pk, "e") stale = R.create(pk, "e")
stale.data["updated"] = (datetime.now(timezone.utc) - timedelta(days=9)).isoformat() stale.data["updated"] = (datetime.now(timezone.utc) - timedelta(days=9)).isoformat()
stale.path.write_text(__import__("json").dumps(stale.data)) stale.path.write_text(__import__("json").dumps(stale.data))
c = R.scorecard(pk) c = R.scorecard(pk)
assert (c["tried"], c["succeeded"], c["failed"], c["abandoned"], c["open"]) == (5, 1, 1, 2, 1) assert (c["tried"], c["succeeded"], c["failed"], c["abandoned"], c["stalled"]) == (5, 1, 1, 2, 1)
assert c["feedback"]["good"] == 1 assert c["feedback"]["good"] == 1

View file

@ -236,7 +236,7 @@ def test_score_runs_list_and_finalize_flow(srv):
base = loc.split("#")[0] base = loc.split("#")[0]
i = int(base.split("/")[2]) i = int(base.split("/")[2])
_, _, body = req(srv, "GET", f"/pack/{i}") _, _, body = req(srv, "GET", f"/pack/{i}")
assert "tried 1 · succeeded 0" in body and "open 1" in body and "Runs" in body assert "tried 1 · succeeded 0" in body and "stalled 1" in body and "Runs" in body
_, _, body = req(srv, "GET", base) _, _, body = req(srv, "GET", base)
assert "Abandon run" in body and "Finish: succeeded" not in body # cannot succeed before every step is done assert "Abandon run" in body and "Finish: succeeded" not in body # cannot succeed before every step is done
_finish_showcase(srv, t, base) _finish_showcase(srv, t, base)

View file

@ -61,7 +61,7 @@ CASES = [
("gate without goal", lambda d: gate(d, "lamp-check").pop("goal"), "goal"), ("gate without goal", lambda d: gate(d, "lamp-check").pop("goal"), "goal"),
("waiver must need a reason", lambda d: gate(d, "lamp-check").update(waivable={"by": "harbourmaster", "needs_reason": False}), "needs_reason"), ("waiver must need a reason", lambda d: gate(d, "lamp-check").update(waivable={"by": "harbourmaster", "needs_reason": False}), "needs_reason"),
("bad timeout action", lambda d: gate(d, "lamp-check").update(timeout={"seconds": 5, "on_timeout": "explode"}), "on_timeout"), ("bad timeout action", lambda d: gate(d, "lamp-check").update(timeout={"seconds": 5, "on_timeout": "explode"}), "on_timeout"),
("bad autopilot value", lambda d: step(d, "close-log").update(autopilot="always"), "autopilot"), ("bad auto_confirm value", lambda d: step(d, "close-log").update(auto_confirm="always"), "auto_confirm"),
] ]
@ -110,3 +110,23 @@ def test_legacy_v02_needs_only_schema():
def test_gate_cannot_confirm_a_datapoint_it_requires(): def test_gate_cannot_confirm_a_datapoint_it_requires():
out = errs(lambda d: next(x for x in d["datapoints"] if x["id"] == "lamp_lit").update(confirmed_by="lamp-check")) out = errs(lambda d: next(x for x in d["datapoints"] if x["id"] == "lamp_lit").update(confirmed_by="lamp-check"))
assert any("circular" in e for e in out), out assert any("circular" in e for e in out), out
def test_idempotency_is_declared_validated_and_summarized():
from rtut import flow
assert P.load(FILE).ok
assert flow.pack_idempotency(GOOD) == "non-idempotent" # write-logbook appends
d = copy.deepcopy(GOOD)
for st in d["stages"]:
for s in st.get("steps", []):
s["idempotent"] = "idempotent"
s.pop("idempotent_note", None)
assert flow.pack_idempotency(d) == "idempotent" and v2.validate(d) == []
step(d, "close-log")["idempotent"] = "conditional"
assert any("idempotent_note" in e for e in v2.validate(d))
step(d, "close-log")["idempotent_note"] = "only while the log file exists"
assert v2.validate(d) == [] and flow.pack_idempotency(d) == "conditional"
step(d, "close-log").pop("idempotent")
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

View file

@ -159,7 +159,7 @@ priority: high
state_hub_task_id: "99320417-8261-556b-8932-4947cfd8ce11" state_hub_task_id: "99320417-8261-556b-8932-4947cfd8ce11"
``` ```
Unblocked: RBT-WP-0007 T06-T07 are finished. Choose manual or autopilot per run (with timeout and Unblocked: RBT-WP-0007 T06-T07 are finished. Choose manual or auto-confirm per run (with timeout and
push-through controls). In manual mode, an override dialog lists unmet preconditions and push-through controls). In manual mode, an override dialog lists unmet preconditions and
the downstream risk before continuing; deviations show in the overview. the downstream risk before continuing; deviations show in the overview.

View file

@ -78,7 +78,7 @@ state_hub_task_id: "0933ff4a-8478-50d9-aa99-991948bc2379"
Derive confirm button versus typed phrase from `reversible`; enforce the phrase in the Derive confirm button versus typed phrase from `reversible`; enforce the phrase in the
engine so no renderer can skip it. engine so no renderer can skip it.
## Operating modes: manual and autopilot ## Operating modes: manual and auto-confirm
```task ```task
id: RBT-WP-0007-T06 id: RBT-WP-0007-T06
@ -88,8 +88,8 @@ state_hub_task_id: "1656597c-3748-5480-b136-e2872436bab4"
``` ```
A run has a mode (`docs/pack-spec-v0.2.md`). Manual: the operator confirms; every A run has a mode (`docs/pack-spec-v0.2.md`). Manual: the operator confirms; every
check, gate and precondition is shown with its state. Autopilot: the engine confirms check, gate and precondition is shown with its state. Auto-confirm: the engine confirms
when all preconditions are met. `autopilot: manual-only` steps and irreversible steps when all preconditions are met. `auto_confirm: manual-only` steps and irreversible steps
are never auto-confirmed without a recorded pre-authorization. are never auto-confirmed without a recorded pre-authorization.
## Overrides, waivers, push-through and the deviation record ## Overrides, waivers, push-through and the deviation record
@ -103,7 +103,7 @@ state_hub_task_id: "0ef958ef-88b6-565b-8797-df730cf11ad3"
Override in manual mode when preconditions are unmet: name the unmet items and the Override in manual mode when preconditions are unmet: name the unmet items and the
declared `downstream_risk`, require explicit confirmation and a reason, record a declared `downstream_risk`, require explicit confirmation and a reason, record a
deviation. Waivers only where a gate declares `waivable`. Autopilot timeouts deviation. Waivers only where a gate declares `waivable`. Auto-confirm timeouts
(`stop` or `push-through`) record a deviation and, for push-through, move on. A run with (`stop` or `push-through`) record a deviation and, for push-through, move on. A run with
any deviation is flagged and can never exercise a pack. any deviation is flagged and can never exercise a pack.
@ -111,7 +111,7 @@ any deviation is flagged and can never exercise a pack.
`rtut/flow.py` and `rtut/run2.py`, documented in `docs/run-spec.md` (run record v0.2) and covered by `rtut/flow.py` and `rtut/run2.py`, documented in `docs/run-spec.md` (run record v0.2) and covered by
`tests/test_run2.py` (25 tests: readiness and recommendation, parallel items, alternatives, datapoint `tests/test_run2.py` (25 tests: readiness and recommendation, parallel items, alternatives, datapoint
states, gates, overrides, waivers, confirmation phrase, autopilot confirm / stop / push-through, states, gates, overrides, waivers, confirmation phrase, auto-confirm confirm / stop / push-through,
manual-only and pre-authorization, read-only finished runs). Two spec corrections came out of it: manual-only and pre-authorization, read-only finished runs). Two spec corrections came out of it:
a gate cannot require a datapoint it is itself `confirmed_by` (validator rule), and `run.finalize` a gate cannot require a datapoint it is itself `confirmed_by` (validator rule), and `run.finalize`
also requires gates passed or waived. Not done here: any UI or CLI for v0.2 runs (RBT-WP-0004). also requires gates passed or waived. Not done here: any UI or CLI for v0.2 runs (RBT-WP-0004).

View file

@ -0,0 +1,103 @@
---
id: RBT-WP-0008
type: workplan
title: "Runbook maturity, projects and idempotency"
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-0007]
---
## Goal
Separate what a runbook is (characteristics), how far it can be trusted (maturity, earned and bound to a
pack revision), and what state an environment is in (projects). Replaces the environment-dependent
`exercise_status`. Model and candidate terminology: `docs/maturity-and-projects.md`.
## Decide the terminology and model
```task
id: RBT-WP-0008-T01
status: progress
priority: high
```
Review `docs/maturity-and-projects.md` with Bernd: names (project or target, environment class), maturity
ladder and thresholds, project states, where project files live, retry policy for non-idempotent steps.
## Idempotency as a declared characteristic
```task
id: RBT-WP-0008-T02
status: done
priority: high
```
Done 2026-09-29: `idempotent: idempotent | non-idempotent | conditional` (+ `idempotent_note`) per step in the v0.2
schema, validator rule for `conditional`, derived runbook value (`flow.pack_idempotency`), declared honestly in the
showcase. Values are words because YAML reads unquoted yes/no as booleans. Follow-ups: show it in the UI, and use
it for the retry policy (T07).
## Bind runs to a pack revision
```task
id: RBT-WP-0008-T03
status: wait
priority: high
```
Record a digest of the pack directory in every run so maturity and project state can be tied to the revision used,
and expire when it changes.
## Derive maturity from run history
```task
id: RBT-WP-0008-T04
status: wait
priority: high
```
`draft`, `reviewed`, `rehearsed`, `proven`, `stale`, derived from clean runs (no deviations) in rehearsal and other
environments, replacing `exercise_status` and the "Record pack as exercised" click.
## Project specification and shared datapoints
```task
id: RBT-WP-0008-T05
status: wait
priority: high
```
`project.yaml`: environment class, shared non-secret datapoints provided as defaults to member runbooks, member
runbooks and ordering. Validator, and runs that belong to a project.
## Project state
```task
id: RBT-WP-0008-T06
status: wait
priority: medium
```
Derive `planned`, `applying`, `applied`, `stale` (later `drifted`) from each member's latest run at the current revision.
## Retry policy for non-idempotent steps
```task
id: RBT-WP-0008-T07
status: wait
priority: medium
```
Retrying a failed non-idempotent step requires the operator to confirm the earlier attempt did not partly apply.
## Acceptance Criteria
- "Exercised" has a precise, environment-aware meaning; a production project is never called "exercised".
- Maturity is derived from runs, bound to a revision, and expires when the pack changes.
- A value shared by several runbooks is entered once, in the project.